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:
+8
-62
@@ -82,69 +82,15 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it
|
|||||||
run the Pi's hotspot/SSID the same way `app_network.py` currently does.
|
run the Pi's hotspot/SSID the same way `app_network.py` currently does.
|
||||||
|
|
||||||
### FEAT-05 — DTC history synced to remote server with code descriptions
|
### FEAT-05 — DTC history synced to remote server with code descriptions
|
||||||
- **Status**: `IDEA`
|
- **Status**: `SHIPPED` 2026-08-12
|
||||||
- **Area**: `modules/app_upload.py`, `modules/app_sql.py`, `server/sql/`, `server/modules/db.py`,
|
- **Area**: `modules/app_upload.py`, `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`, `server/templates/dtc.html`
|
||||||
`server/app.py`, server portal templates
|
|
||||||
- **Phase**: Phase 27
|
- **Phase**: Phase 27
|
||||||
- **Requested**: 2026-08-11 — owner wants DTC codes persisted to the server (not just local SQLite)
|
- **What**: Pi pushes current DTC snapshot to server via `POST /api/v1/dtc/batch`.
|
||||||
with code + description, surviving until user deletes them or OBD reports the code cleared.
|
Server diffs against stored state — new codes inserted, existing updated, missing
|
||||||
- **Current state (2026-08-11)**:
|
codes auto-cleared. Portal has a new `/dtc` page showing code, description, MIL
|
||||||
- Pi: `app_obd.py` polls DTCs from ECU → `app_sql.save_dtc_state()` persists MIL + codes
|
state, active/cleared status, first/last seen timestamps, and manual delete.
|
||||||
(code, description, IS_SET, FIRST_SEEN_AT, LAST_SEEN_AT) to local SQLite `dtc` table.
|
Upload skips when OBD disconnected (avoids clearing server DTCs on Pi reboot).
|
||||||
- Pi: `app_upload.py` uploads tracking rows and dashcam files — NO DTC upload path exists.
|
Snapshot is hash-compared to avoid redundant uploads.
|
||||||
- Server: NO `dtc_events` table, NO DTC API, NO portal DTC page.
|
|
||||||
- **Design**:
|
|
||||||
|
|
||||||
**Server schema** (`server/sql/migrate_phase26_dtc.sql`):
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS dtc_events (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
||||||
code VARCHAR(10) NOT NULL,
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
mil_on BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
cleared_at TIMESTAMPTZ,
|
|
||||||
UNIQUE (device_id, code)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server API** (`server/app.py`):
|
|
||||||
- `POST /api/v1/dtc/batch` (device auth): receives current DTC snapshot from Pi.
|
|
||||||
Body: `{"mil_on": true/false, "codes": [{"code": "P0301", "description": "Cylinder 1 Misfire"}, ...]}`
|
|
||||||
- Insert new codes, update `last_seen_at` for existing active codes.
|
|
||||||
- Mark any previously-active codes NOT in this batch as `is_active=false, cleared_at=now()`.
|
|
||||||
- Return `{received: N, cleared: M, unchanged: K}`.
|
|
||||||
- `GET /api/v1/portal/dtc` (user auth): list all DTC events for configured device.
|
|
||||||
- `DELETE /api/v1/portal/dtc/<id>` (user auth): manual deletion.
|
|
||||||
|
|
||||||
**Server portal**: new DTC page or section showing code, description, MIL state, first/last seen,
|
|
||||||
active/cleared status, cleared timestamp.
|
|
||||||
|
|
||||||
**Pi upload** (`modules/app_upload.py`):
|
|
||||||
- New `_upload_dtc_snapshot()` — called after tracking batch in `_upload_loop()`.
|
|
||||||
- Only uploads when the local DTC snapshot differs from last-uploaded (hash or version compare).
|
|
||||||
- Reuses same `_credentials()`/`_headers()` as tracking uploads.
|
|
||||||
- Same WiFi-first, cellular-fallback (if configured) path as tracking batches.
|
|
||||||
|
|
||||||
- **Deduplication logic**: server-side per `(device_id, code)` unique key. Pi sends the full
|
|
||||||
current snapshot each time. Server diffs: new codes = insert, existing = touch last_seen_at,
|
|
||||||
missing from snapshot = mark cleared. This means if a code disappears from the ECU, it
|
|
||||||
auto-clears on the server on the next upload — no manual "clear" step needed.
|
|
||||||
- **Manual deletion**: only via the server portal delete button. Cleared codes stay visible as
|
|
||||||
history. The Pi never deletes from the server automatically.
|
|
||||||
- **Outstanding considerations**:
|
|
||||||
- The Pi's local `save_dtc_state()` already handles IS_SET/cleared tracking — the upload path
|
|
||||||
just needs to read the current active snapshot from SQLite (reuse `get_persisted_dtc_state()`).
|
|
||||||
- DTC descriptions come directly from the ECU (python-obd's `GET_DTC` response) — they're the
|
|
||||||
standard SAE J2012 descriptions, no external lookup needed.
|
|
||||||
- Should we upload on every poll (every ~2s OBD cycle) or batch? Answer: batch. Upload alongside
|
|
||||||
tracking batches (every 30s idle, 1s catchup). DTCs don't change mid-drive that often.
|
|
||||||
- First upload after Pi reboot with no OBD connected: snapshot is empty → server marks all
|
|
||||||
previously-active codes as cleared. Is that desired? Probably not — should skip upload when
|
|
||||||
OBD is disconnected/unavailable (the Pi already knows this from `connection` state).
|
|
||||||
|
|
||||||
### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id)
|
### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id)
|
||||||
- **Status**: `SHIPPED` 2026-08-11 (Phase 22)
|
- **Status**: `SHIPPED` 2026-08-11 (Phase 22)
|
||||||
|
|||||||
@@ -1945,35 +1945,25 @@ agree on OBD and GPS state. BUGS.md Open section is empty. readme.md project sta
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 27: Feature additions — ✅ DONE (3 shipped, 4 candidates remain)
|
### Phase 27: Feature additions — ✅ DONE (4 shipped, 3 candidates remain)
|
||||||
**Completed 2026-08-12.** Three features shipped; remaining 4 are low-priority ideas
|
**Completed 2026-08-12.** Four features shipped; remaining 3 are low-priority ideas
|
||||||
tracked in FEATURES.md.
|
tracked in FEATURES.md.
|
||||||
|
|
||||||
**Shipped:**
|
**Shipped:**
|
||||||
- **FEAT-03 — Manual odometer baseline** (2026-08-11): Pi settings has "Odometer baseline
|
- **FEAT-03 — Manual odometer baseline** (2026-08-11)
|
||||||
(km)". Dashboard shows `odometer_km = baseline + trip distance`. Pushes to server on save
|
- **FEAT-06 — Trip ID column in history** (2026-08-11)
|
||||||
via `POST /api/device-config/push`. Portal shows read-only synced value. Server overview
|
- **FEAT-07 — Phone-hotspot auto-connect docs** (2026-08-11)
|
||||||
stat card. Four sync bugs fixed during implementation:
|
- **FEAT-05 — DTC history sync to server** (2026-08-12): Pi pushes DTC snapshots
|
||||||
1. Sync overwrote Pi odometer with stale server value (`_store_synced_config` no longer touches it).
|
via `POST /api/v1/dtc/batch`. Server diffs: insert/update/auto-clear. Portal
|
||||||
2. JS skipped `POST /api/device-config` when enabling sync (always-send fix).
|
`/dtc` page with code, description, MIL, status, timestamps, delete. Skips
|
||||||
3. Backend returned error when toggling sync with locked fields present (toggle-first fix).
|
upload when OBD disconnected. Changed: `modules/app_upload.py`,
|
||||||
4. `get_device_by_id()` missing `odometer_baseline` in SQL SELECT (added column, shifted indices).
|
`server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`,
|
||||||
Changed: `modules/app_settings.py`, `modules/app_sql.py`, `modules/app_device_sync.py`,
|
`server/templates/dtc.html`, `server/templates/base.html`.
|
||||||
`modules/app_device_config.py`, `templates/settings.html`, `static/js/settings.js`,
|
|
||||||
`server/modules/db.py`, `server/modules/device_config.py`,
|
|
||||||
`server/templates/device_settings.html`, `static/js/portal-device-settings.js`,
|
|
||||||
`server/app.py`, `FEATURES.md`.
|
|
||||||
- **FEAT-06 — Trip ID column in history** (2026-08-11): Narrow `#ID` column on server
|
|
||||||
history (`data-show-id="1"`). Checkbox + batch-delete server-only (`data-batch-delete-url`).
|
|
||||||
Changed: `static/js/history.js`, `server/templates/history.html`.
|
|
||||||
- **FEAT-07 — Phone-hotspot auto-connect docs** (2026-08-11): Already implemented in
|
|
||||||
Phase 22; documented and verified with real Pixel hardware.
|
|
||||||
|
|
||||||
**Remaining candidates (4):**
|
**Remaining candidates (3):**
|
||||||
1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium)
|
1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium)
|
||||||
2. **FEAT-02** — QR / claim-code device pairing flow (medium)
|
2. **FEAT-02** — QR / claim-code device pairing flow (medium)
|
||||||
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large)
|
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large)
|
||||||
4. **FEAT-05** — DTC history synced to remote server (medium)
|
|
||||||
|
|
||||||
### Phase 28: Native Android companion app — 🚧 IN PROGRESS (low priority, always last)
|
### Phase 28: Native Android companion app — 🚧 IN PROGRESS (low priority, always last)
|
||||||
**Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
|
**Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.ButtonDefaults
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -43,6 +44,7 @@ fun DashcamScreen(
|
|||||||
retentionDays: Int,
|
retentionDays: Int,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
downloadProgress: Map<String, DownloadProgress>,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onDownload: (VideoFile) -> Unit,
|
onDownload: (VideoFile) -> Unit,
|
||||||
onDelete: (VideoFile) -> Unit,
|
onDelete: (VideoFile) -> Unit,
|
||||||
@@ -146,8 +148,10 @@ fun DashcamScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(videos, key = { it.path }) { video ->
|
items(videos, key = { it.path }) { video ->
|
||||||
|
val progress = downloadProgress[video.path]
|
||||||
VideoRow(
|
VideoRow(
|
||||||
video = video,
|
video = video,
|
||||||
|
downloadProgress = progress,
|
||||||
onDownload = { onDownload(video) },
|
onDownload = { onDownload(video) },
|
||||||
onDelete = { onDelete(video) },
|
onDelete = { onDelete(video) },
|
||||||
)
|
)
|
||||||
@@ -161,6 +165,7 @@ fun DashcamScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun VideoRow(
|
private fun VideoRow(
|
||||||
video: VideoFile,
|
video: VideoFile,
|
||||||
|
downloadProgress: DownloadProgress?,
|
||||||
onDownload: () -> Unit,
|
onDownload: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -224,6 +229,20 @@ private fun VideoRow(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
)
|
)
|
||||||
|
} else if (downloadProgress != null) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = {
|
||||||
|
if (downloadProgress.totalBytes > 0)
|
||||||
|
downloadProgress.bytesDownloaded.toFloat() / downloadProgress.totalBytes
|
||||||
|
else 0f
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Downloading ${downloadProgress.bytesDownloaded / 1_000_000}MB / ${downloadProgress.totalBytes / 1_000_000}MB",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (video.uploaded) "Uploaded" else "Pending",
|
text = if (video.uploaded) "Uploaded" else "Pending",
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -49,10 +52,20 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
val retentionDays by dashcamVm.retentionDays.collectAsState()
|
val retentionDays by dashcamVm.retentionDays.collectAsState()
|
||||||
val isLoading by dashcamVm.isLoading.collectAsState()
|
val isLoading by dashcamVm.isLoading.collectAsState()
|
||||||
val error by dashcamVm.error.collectAsState()
|
val error by dashcamVm.error.collectAsState()
|
||||||
|
val downloadProgress by dashcamVm.downloadProgress.collectAsState()
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Show download/delete events as snackbars
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dashcamVm.downloadEvents.collect { event ->
|
||||||
|
snackbarHostState.showSnackbar(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
@@ -78,6 +91,7 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
retentionDays = retentionDays,
|
retentionDays = retentionDays,
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
error = error,
|
error = error,
|
||||||
|
downloadProgress = downloadProgress,
|
||||||
onRefresh = { dashcamVm.loadVideos() },
|
onRefresh = { dashcamVm.loadVideos() },
|
||||||
onDownload = { dashcamVm.downloadVideo(it) },
|
onDownload = { dashcamVm.downloadVideo(it) },
|
||||||
onDelete = { dashcamVm.deleteVideo(it) },
|
onDelete = { dashcamVm.deleteVideo(it) },
|
||||||
|
|||||||
+46
-1
@@ -74,12 +74,57 @@ login_manager.init_app(app)
|
|||||||
|
|
||||||
|
|
||||||
def _portal_device_id():
|
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")
|
name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
||||||
dev = db.get_device_by_name(name)
|
dev = db.get_device_by_name(name)
|
||||||
return dev["id"] if dev else None
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
"""Unauthenticated liveness check - used by docker-compose healthcheck
|
"""Unauthenticated liveness check - used by docker-compose healthcheck
|
||||||
|
|||||||
@@ -86,7 +86,12 @@
|
|||||||
<span class="fw-bold text-dark">Yaris OBD</span>
|
<span class="fw-bold text-dark">Yaris OBD</span>
|
||||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
|
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
|
||||||
</a>
|
</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>
|
<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">
|
<a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout">
|
||||||
<i class="fas fa-sign-out-alt fa-fw"></i>
|
<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/bs-init.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/theme.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>
|
<script>
|
||||||
window.confirmDelete = function(message) {
|
window.confirmDelete = function(message) {
|
||||||
return Promise.resolve(window.confirm(message));
|
return Promise.resolve(window.confirm(message));
|
||||||
|
|||||||
Reference in New Issue
Block a user