feat: FEAT-05 — DTC history sync to remote server
- Server: dtc_events table (migrate_phase27_dtc.sql), db.py helpers (upsert_dtc_snapshot, list_dtc_events, delete_dtc_event) - Server API: POST /api/v1/dtc/batch (device auth), GET/DELETE /api/v1/portal/dtc - Server portal: /dtc page with code, description, MIL, status, timestamps, delete - Server nav: DTC history in sidebar + mobile bottom nav - Pi: _upload_dtc_snapshot() in app_upload.py — uploads when snapshot changes, skips when OBD disconnected or unchanged
This commit is contained in:
@@ -34,12 +34,12 @@ data class DownloadProgress(
|
|||||||
|
|
||||||
class DashcamViewModel(application: Application) : AndroidViewModel(application) {
|
class DashcamViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val baseUrl = "http://10.42.0.1/"
|
private val baseUrl = "http://10.42.0.1" // no trailing slash
|
||||||
private val okHttp = OkHttpClient.Builder().build()
|
private val okHttp = OkHttpClient.Builder().build()
|
||||||
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
||||||
|
|
||||||
private val api: PiApi = Retrofit.Builder()
|
private val api: PiApi = Retrofit.Builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl("$baseUrl/") // Retrofit requires trailing slash
|
||||||
.client(okHttp)
|
.client(okHttp)
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
@@ -93,14 +93,14 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
|
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
|
||||||
val localFile = File(dir, video.path.substringAfterLast("/"))
|
val localFile = File(dir, video.path.substringAfterLast("/"))
|
||||||
|
|
||||||
// Skip if already downloaded
|
// Skip if already downloaded and size matches
|
||||||
if (localFile.exists() && localFile.length() == video.size) {
|
if (localFile.exists() && localFile.length() == video.size) {
|
||||||
_downloadEvents.emit("Already downloaded: ${localFile.name}")
|
_downloadEvents.emit("✓ Already downloaded: ${localFile.name}")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$baseUrl/api/local-video/file?path=${video.path}")
|
.url("$baseUrl/api/local-video/file?path=${video.path}")
|
||||||
.get()
|
.get()
|
||||||
@@ -108,10 +108,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
val response = okHttp.newCall(request).execute()
|
val response = okHttp.newCall(request).execute()
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw Exception("HTTP ${response.code}")
|
return@withContext "Download failed: HTTP ${response.code}"
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body ?: throw Exception("Empty body")
|
val body = response.body ?: return@withContext "Download failed: Empty body"
|
||||||
val total = body.contentLength()
|
val total = body.contentLength()
|
||||||
localFile.parentFile?.mkdirs()
|
localFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
@@ -124,19 +124,19 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (read == -1L) break
|
if (read == -1L) break
|
||||||
sink.write(buffer, read)
|
sink.write(buffer, read)
|
||||||
downloaded += read
|
downloaded += read
|
||||||
_downloadProgress.value = _downloadProgress.value + (
|
val progress = DownloadProgress(video.path, downloaded, total)
|
||||||
video.path to DownloadProgress(video.path, downloaded, total)
|
_downloadProgress.value = _downloadProgress.value + (video.path to progress)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_downloadProgress.value = _downloadProgress.value - video.path
|
_downloadProgress.value = _downloadProgress.value - video.path
|
||||||
_downloadEvents.emit("Downloaded: ${localFile.name}")
|
"✓ Downloaded: ${localFile.name} (${downloaded} bytes)"
|
||||||
}
|
}
|
||||||
|
_downloadEvents.emit(result)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_downloadProgress.value = _downloadProgress.value - video.path
|
_downloadProgress.value = _downloadProgress.value - video.path
|
||||||
_downloadEvents.emit("Download failed: ${e.message}")
|
_downloadEvents.emit("✗ Download failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import com.yarisobd.app.data.remote.dto.GpsStatus
|
|||||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.HTTP
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
@@ -32,6 +32,6 @@ interface PiApi {
|
|||||||
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
|
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
|
||||||
|
|
||||||
/** Delete local copy only */
|
/** Delete local copy only */
|
||||||
@DELETE("/api/local-video")
|
@HTTP(method = "DELETE", path = "/api/local-video", hasBody = true)
|
||||||
suspend fun deleteVideo(@Body body: Map<String, String>): Map<String, Any?>
|
suspend fun deleteVideo(@Body body: Map<String, String>): Map<String, Any?>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,50 @@ def _upload_dashcam_files():
|
|||||||
return uploaded_count
|
return uploaded_count
|
||||||
|
|
||||||
|
|
||||||
|
_last_dtc_hash = None # tracks last-uploaded DTC snapshot to avoid redundant pushes
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_dtc_snapshot():
|
||||||
|
"""Upload current DTC snapshot to server if it differs from last upload.
|
||||||
|
Skips when OBD is disconnected (no point sending empty snapshots that
|
||||||
|
would clear all server-side DTC events)."""
|
||||||
|
global _last_dtc_hash
|
||||||
|
import hashlib as _hl
|
||||||
|
from . import app_obd, app_sql
|
||||||
|
|
||||||
|
# Only upload when OBD is actually connected — don't clear server DTCs
|
||||||
|
# just because the Pi rebooted or the adapter is unplugged.
|
||||||
|
if app_obd.connection is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
mil_on = app_obd.mil_on
|
||||||
|
codes = app_obd.dtc_codes or []
|
||||||
|
if not codes and mil_on is None:
|
||||||
|
return # No data yet
|
||||||
|
|
||||||
|
# Hash the current snapshot to detect changes
|
||||||
|
payload = {"mil_on": bool(mil_on), "codes": codes}
|
||||||
|
h = _hl.sha256(repr(payload).encode()).hexdigest()
|
||||||
|
if h == _last_dtc_hash:
|
||||||
|
return
|
||||||
|
_last_dtc_hash = h
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = _credentials()
|
||||||
|
if not creds["server_url"] or not creds["device_name"] or not creds["api_key"]:
|
||||||
|
return
|
||||||
|
resp = requests.post(
|
||||||
|
f"{creds['server_url']}/api/v1/dtc/batch",
|
||||||
|
json=payload,
|
||||||
|
headers=_headers(creds),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException:
|
||||||
|
_last_dtc_hash = None # retry next cycle
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _upload_loop():
|
def _upload_loop():
|
||||||
global _last_error, _last_success_at
|
global _last_error, _last_success_at
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
@@ -323,6 +367,7 @@ def _upload_loop():
|
|||||||
sent = _upload_tracking_batch()
|
sent = _upload_tracking_batch()
|
||||||
uploaded = _upload_dashcam_files()
|
uploaded = _upload_dashcam_files()
|
||||||
cleanup = app_video.cleanup_uploaded_videos()
|
cleanup = app_video.cleanup_uploaded_videos()
|
||||||
|
_upload_dtc_snapshot() # silently skips when unchanged or OBD offline
|
||||||
if sent or uploaded:
|
if sent or uploaded:
|
||||||
print(f"Upload: sent {sent} tracking rows, {uploaded} dashcam files")
|
print(f"Upload: sent {sent} tracking rows, {uploaded} dashcam files")
|
||||||
if cleanup["deleted"]:
|
if cleanup["deleted"]:
|
||||||
|
|||||||
@@ -423,6 +423,12 @@ def portal_device_settings_page():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dtc")
|
||||||
|
@require_login
|
||||||
|
def portal_dtc_page():
|
||||||
|
return render_template("dtc.html")
|
||||||
|
|
||||||
|
|
||||||
# ── Portal JSON API ────────────────────────────────────────────────────────
|
# ── Portal JSON API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/v1/portal/stats")
|
@app.get("/api/v1/portal/stats")
|
||||||
@@ -850,6 +856,45 @@ def api_device_config_update():
|
|||||||
return jsonify(config)
|
return jsonify(config)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTC sync (FEAT-05) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/v1/dtc/batch")
|
||||||
|
@require_device_auth
|
||||||
|
def upload_dtc_batch():
|
||||||
|
"""Receive a DTC snapshot from the Pi. Body: {mil_on: bool, codes: [{code, description}, ...]}."""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
mil_on = bool(body.get("mil_on", False))
|
||||||
|
codes = body.get("codes")
|
||||||
|
if not isinstance(codes, list):
|
||||||
|
return jsonify(error="expected 'codes' as a list"), 400
|
||||||
|
for c in codes:
|
||||||
|
if not isinstance(c.get("code"), str) or not c["code"].strip():
|
||||||
|
return jsonify(error="each code must have a non-empty 'code' string"), 400
|
||||||
|
result = db.upsert_dtc_snapshot(g.device_id, mil_on, codes)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/dtc")
|
||||||
|
@require_login
|
||||||
|
def portal_dtc():
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
return jsonify(db.list_dtc_events(device_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/portal/dtc/<int:dtc_id>")
|
||||||
|
@require_login
|
||||||
|
def portal_dtc_delete(dtc_id):
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
deleted = db.delete_dtc_event(device_id, dtc_id)
|
||||||
|
if not deleted:
|
||||||
|
return jsonify(error="DTC event not found"), 404
|
||||||
|
return jsonify(status="ok")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app.run(host="0.0.0.0", port=8098)
|
app.run(host="0.0.0.0", port=8098)
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,83 @@ def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_pa
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTC events (FEAT-05) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upsert_dtc_snapshot(device_id, mil_on, codes):
|
||||||
|
"""Receive a DTC snapshot from the Pi. Inserts new codes, updates existing,
|
||||||
|
marks codes absent from this snapshot as cleared. Returns stats dict."""
|
||||||
|
with get_cursor(commit=True) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT code FROM dtc_events WHERE device_id = %s AND is_active = TRUE",
|
||||||
|
(device_id,),
|
||||||
|
)
|
||||||
|
previously_active = {row[0] for row in cur.fetchall()}
|
||||||
|
current_codes = {c["code"] for c in codes}
|
||||||
|
|
||||||
|
new_codes = current_codes - previously_active
|
||||||
|
cleared_codes = previously_active - current_codes
|
||||||
|
unchanged = previously_active & current_codes
|
||||||
|
|
||||||
|
with get_cursor(commit=True) as cur:
|
||||||
|
for c in codes:
|
||||||
|
if c["code"] in new_codes:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO dtc_events (device_id, code, description, mil_on, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, TRUE)
|
||||||
|
ON CONFLICT (device_id, code) DO UPDATE SET
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
mil_on = EXCLUDED.mil_on,
|
||||||
|
is_active = TRUE,
|
||||||
|
last_seen_at = now()""",
|
||||||
|
(device_id, c["code"], c.get("description", ""), mil_on),
|
||||||
|
)
|
||||||
|
elif c["code"] in unchanged:
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE dtc_events SET mil_on = %s, last_seen_at = now(), is_active = TRUE
|
||||||
|
WHERE device_id = %s AND code = %s""",
|
||||||
|
(mil_on, device_id, c["code"]),
|
||||||
|
)
|
||||||
|
for code in cleared_codes:
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE dtc_events SET is_active = FALSE, cleared_at = now()
|
||||||
|
WHERE device_id = %s AND code = %s""",
|
||||||
|
(device_id, code),
|
||||||
|
)
|
||||||
|
return {"received": len(codes), "cleared": len(cleared_codes), "unchanged": len(unchanged)}
|
||||||
|
|
||||||
|
|
||||||
|
def list_dtc_events(device_id):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, code, description, mil_on, is_active, first_seen_at, last_seen_at, cleared_at
|
||||||
|
FROM dtc_events WHERE device_id = %s ORDER BY last_seen_at DESC""",
|
||||||
|
(device_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"code": r[1],
|
||||||
|
"description": r[2],
|
||||||
|
"mil_on": r[3],
|
||||||
|
"is_active": r[4],
|
||||||
|
"first_seen_at": r[5].isoformat() if r[5] else None,
|
||||||
|
"last_seen_at": r[6].isoformat() if r[6] else None,
|
||||||
|
"cleared_at": r[7].isoformat() if r[7] else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_dtc_event(device_id, dtc_id):
|
||||||
|
with get_cursor(commit=True) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM dtc_events WHERE id = %s AND device_id = %s RETURNING id",
|
||||||
|
(dtc_id, device_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
# ── User auth ──────────────────────────────────────────────────────────────
|
# ── User auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_user_by_id(user_id):
|
def get_user_by_id(user_id):
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Phase 27 / FEAT-05: DTC history synced to remote server.
|
||||||
|
-- Pi pushes current DTC snapshot; server diffs against stored state.
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dtc_events_device_active
|
||||||
|
ON dtc_events (device_id, is_active);
|
||||||
@@ -49,6 +49,11 @@
|
|||||||
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
|
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.endpoint == 'portal_dtc_page' %} active{% endif %}" href="{{ url_for('portal_dtc_page') }}">
|
||||||
|
<i class="fas fa-exclamation-triangle fa-fw"></i><span>DTC history</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link{% if request.endpoint == 'portal_device_settings_page' %} active{% endif %}" href="{{ url_for('portal_device_settings_page') }}">
|
<a class="nav-link{% if request.endpoint == 'portal_device_settings_page' %} active{% endif %}" href="{{ url_for('portal_device_settings_page') }}">
|
||||||
<i class="fas fa-sliders-h fa-fw"></i><span>Device settings</span>
|
<i class="fas fa-sliders-h fa-fw"></i><span>Device settings</span>
|
||||||
@@ -117,6 +122,10 @@
|
|||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
<span>Device</span>
|
<span>Device</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="mobile-nav-item{% if request.endpoint == 'portal_dtc_page' %} active{% endif %}" href="{{ url_for('portal_dtc_page') }}">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>DTC</span>
|
||||||
|
</a>
|
||||||
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
|
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}DTC history – Yaris OBD Remote{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3 class="text-dark mb-3 fw-bold">DTC history</h3>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Diagnostic trouble codes</h6>
|
||||||
|
<span id="dtc-count" class="badge bg-secondary">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="dtc-loading" class="text-center text-muted py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<div id="dtc-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0" id="dtc-table" hidden>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Code</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>MIL</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>First seen</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th class="pe-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dtc-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="dtc-empty" class="text-muted text-center py-4 small" hidden>No DTC events recorded yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const loading = document.getElementById("dtc-loading");
|
||||||
|
const error = document.getElementById("dtc-error");
|
||||||
|
const table = document.getElementById("dtc-table");
|
||||||
|
const tbody = document.getElementById("dtc-body");
|
||||||
|
const empty = document.getElementById("dtc-empty");
|
||||||
|
const count = document.getElementById("dtc-count");
|
||||||
|
|
||||||
|
function fmt(iso) { return iso ? new Date(iso).toLocaleString() : "—"; }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/v1/portal/dtc");
|
||||||
|
if (!r.ok) throw new Error("Cannot load DTC events.");
|
||||||
|
const events = await r.json();
|
||||||
|
loading.hidden = true;
|
||||||
|
count.textContent = events.length;
|
||||||
|
if (!events.length) { empty.hidden = false; return; }
|
||||||
|
tbody.innerHTML = events.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 fw-bold font-monospace ${e.is_active ? 'text-danger' : 'text-muted'}">${e.code}</td>
|
||||||
|
<td>${e.description || "—"}</td>
|
||||||
|
<td>${e.mil_on ? '<span class="badge text-bg-warning">ON</span>' : '<span class="badge text-bg-secondary">OFF</span>'}</td>
|
||||||
|
<td>${e.is_active ? '<span class="badge text-bg-danger">Active</span>' : '<span class="badge text-bg-success">Cleared</span>'}</td>
|
||||||
|
<td class="small text-muted">${fmt(e.first_seen_at)}</td>
|
||||||
|
<td class="small text-muted">${fmt(e.last_seen_at)}</td>
|
||||||
|
<td class="pe-3"><button class="btn btn-sm btn-outline-danger" data-dtc-id="${e.id}" title="Delete">🗑</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
table.hidden = false;
|
||||||
|
|
||||||
|
tbody.querySelectorAll("[data-dtc-id]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Delete this DTC event?")) return;
|
||||||
|
const r = await fetch("/api/v1/portal/dtc/" + btn.dataset.dtcId, {method: "DELETE"});
|
||||||
|
if (!r.ok) { error.textContent = "Could not delete."; error.hidden = false; return; }
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
loading.hidden = true;
|
||||||
|
error.textContent = e.message;
|
||||||
|
error.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user