From 1232d9f6078541b1179e1419ef841f2eab674fed Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Sun, 10 May 2026 20:32:26 -0400 Subject: [PATCH] dashboard: surface the CMD:* protocol in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the host command endpoints added in the previous commit (/api/flock/{status,dump_prev,dump_live,clear_prev,clear_live}) into index.html as a five-button row under the Sniffer connect controls. Buttons stay hidden until the device is connected and are auto-revealed on the loadStatus() poll, the connect-success callback, and any flock_reconnected socket event; hidden again on flock_disconnected. - Pull Prev / Pull Live → POST dump_{prev,live}, show "Pulling…" busy state, toast the final count - Status → GET /api/flock/status, toast a compact line: det=N ouis=N prev=yes ch=N heap=KKB up=Ns - Clear Prev / Clear Live → POST clear_{prev,live} after a confirm() dialog (destructive) A top-right toast element (#flockToast) handles all command feedback with success/warning/error/info colour bands; auto-dismisses after 4s (6s for status, so the user has time to read). Replay detections are visually distinguished in the detection cards: a new "FLASH" or "RAM" badge (purple for SPIFFS, blue for live RAM) appears next to the GPS tag, and the card itself gets a subtle left border + tint via .detection-item.replay. Socket events also wired: replay_detection pushes into both detections[] and cumulativeDetections[]; flock_replay_complete / flock_error trigger their own toasts so other browser tabs see the result of a pull triggered elsewhere; flock_status / flock_clear are logged only (the REST caller already gets toast feedback). All button click handlers disable the other command buttons during a request so a user can't fire two dumps in parallel against the same serial port (which would interleave at the firmware end — the protocol serializes one CMD: at a time). --- api/templates/index.html | 268 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 266 insertions(+), 2 deletions(-) diff --git a/api/templates/index.html b/api/templates/index.html index 285b1b3..7ba9837 100644 --- a/api/templates/index.html +++ b/api/templates/index.html @@ -873,6 +873,82 @@ letter-spacing: 0.3px; } + .replay-badge { + background: #6366f1; + color: white; + padding: 0.15rem 0.4rem; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + } + .replay-badge.live { background: #0ea5e9; } + + .device-extra-controls { + display: flex; + gap: 0.4rem; + margin-top: 0.5rem; + flex-wrap: wrap; + } + .flock-cmd-btn { + background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); + border-color: #4338ca; + font-size: 0.85rem; + padding: 0.4rem 0.7rem; + } + .flock-cmd-btn:hover { + background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%); + } + .flock-cmd-btn:disabled { + background: #475569; + border-color: #334155; + cursor: not-allowed; + opacity: 0.65; + transform: none; + box-shadow: none; + } + .flock-cmd-btn.danger { + background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%); + border-color: #991b1b; + } + .flock-cmd-btn.danger:hover { + background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%); + } + + #flockToast { + position: fixed; + top: 1rem; + right: 1rem; + min-width: 280px; + max-width: 420px; + padding: 0.7rem 1rem; + border-radius: 6px; + background: #1e293b; + color: #e2e8f0; + border-left: 4px solid #6366f1; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-size: 0.9rem; + z-index: 9999; + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; + } + #flockToast.show { + opacity: 1; + transform: translateY(0); + } + #flockToast.success { border-left-color: #22c55e; } + #flockToast.warning { border-left-color: #f59e0b; } + #flockToast.error { border-left-color: #ef4444; } + #flockToast.info { border-left-color: #6366f1; } + + .detection-item.replay { + border-left: 3px solid #6366f1; + background: rgba(99, 102, 241, 0.04); + } + .detection-count { background: #059669; color: white; @@ -1279,6 +1355,8 @@ +
+
@@ -1298,6 +1376,13 @@
+
@@ -1753,6 +1838,7 @@ updateFlockStatus(true); document.getElementById('connectFlockBtn').style.display = 'none'; document.getElementById('disconnectFlockBtn').style.display = 'inline-block'; + setFlockExtraControls(true); } else { alert('Flock You connection failed: ' + data.message); } @@ -1765,7 +1851,7 @@ function disconnectFlock() { console.log('Disconnecting Flock device'); - + fetch('/api/flock/disconnect', { method: 'POST' }) @@ -1776,6 +1862,7 @@ updateFlockStatus(false); document.getElementById('connectFlockBtn').style.display = 'inline-block'; document.getElementById('disconnectFlockBtn').style.display = 'none'; + setFlockExtraControls(false); } }) .catch(error => { @@ -1783,6 +1870,130 @@ }); } + // ============================================================ + // Host command protocol UI (CMD:STATUS / CMD:DUMP_* / CMD:CLEAR_*) + // ============================================================ + + let flockToastTimer = null; + function showFlockToast(message, kind = 'info', timeout = 4000) { + const el = document.getElementById('flockToast'); + if (!el) return; + el.textContent = message; + el.classList.remove('success', 'warning', 'error', 'info'); + el.classList.add(kind); + el.classList.add('show'); + if (flockToastTimer) clearTimeout(flockToastTimer); + if (timeout > 0) { + flockToastTimer = setTimeout(() => { el.classList.remove('show'); }, timeout); + } + } + + function setFlockExtraControls(visible) { + const el = document.getElementById('flockExtraControls'); + if (el) el.style.display = visible ? 'flex' : 'none'; + } + + function setFlockCmdButtonsBusy(busy, exceptId = null) { + ['dumpPrevBtn', 'dumpLiveBtn', 'flockStatusBtn', 'clearPrevBtn', 'clearLiveBtn'].forEach(id => { + const btn = document.getElementById(id); + if (!btn) return; + if (busy && id !== exceptId) btn.disabled = true; + else btn.disabled = false; + }); + } + + function flockPullSession(source) { + const isPrev = source === 'prev'; + const btnId = isPrev ? 'dumpPrevBtn' : 'dumpLiveBtn'; + const btn = document.getElementById(btnId); + if (!btn) return; + const original = btn.textContent; + setFlockCmdButtonsBusy(true, btnId); + btn.textContent = isPrev ? 'Pulling Prev…' : 'Pulling Live…'; + showFlockToast(`Pulling ${isPrev ? 'previous-session' : 'live'} table from device…`, 'info', 0); + + fetch(`/api/flock/dump_${source}`, { method: 'POST' }) + .then(r => r.json()) + .then(data => { + setFlockCmdButtonsBusy(false); + btn.textContent = original; + if (data.status === 'success') { + const reason = data.reason ? ` (${data.reason})` : ''; + showFlockToast( + `Pulled ${data.count ?? 0} detection(s) from device ${isPrev ? 'flash' : 'RAM'}${reason}`, + data.count > 0 ? 'success' : 'warning'); + } else { + const reason = data.reason || data.message || 'unknown error'; + showFlockToast(`Pull failed: ${reason}`, 'error'); + } + }) + .catch(err => { + setFlockCmdButtonsBusy(false); + btn.textContent = original; + showFlockToast(`Pull failed: ${err.message}`, 'error'); + }); + } + + function flockDumpPrev() { flockPullSession('prev'); } + function flockDumpLive() { flockPullSession('live'); } + + function flockStatusQuery() { + const btn = document.getElementById('flockStatusBtn'); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = '…'; + fetch('/api/flock/status') + .then(r => r.json()) + .then(data => { + btn.disabled = false; + btn.textContent = original; + if (data.status === 'success' && data.firmware_status) { + const s = data.firmware_status; + const heapKB = s.free_heap ? Math.round(s.free_heap / 1024) : '?'; + const uptimeSec = s.uptime_ms ? Math.round(s.uptime_ms / 1000) : 0; + showFlockToast( + `det=${s.fy_det} ouis=${s.oui_count} prev=${s.prev_session ? 'yes' : 'no'} ` + + `ch=${s.channel} heap=${heapKB}KB up=${uptimeSec}s`, + 'success', 6000); + } else { + showFlockToast(`Status failed: ${data.message || 'no response'}`, 'error'); + } + }) + .catch(err => { + btn.disabled = false; + btn.textContent = original; + showFlockToast(`Status failed: ${err.message}`, 'error'); + }); + } + + function flockClearSession(target) { + const label = target === 'prev' ? 'previous session on device flash' : 'live detection table on device'; + if (!confirm(`Wipe the ${label}?`)) return; + const btnId = target === 'prev' ? 'clearPrevBtn' : 'clearLiveBtn'; + const btn = document.getElementById(btnId); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = '…'; + fetch(`/api/flock/clear_${target}`, { method: 'POST' }) + .then(r => r.json()) + .then(data => { + btn.disabled = false; + btn.textContent = original; + if (data.status === 'success') { + showFlockToast(`Cleared device ${target === 'prev' ? '/prev_session.json' : 'in-RAM table'}`, 'success'); + } else { + showFlockToast(`Clear failed: ${(data.firmware && data.firmware.reason) || data.message || 'unknown'}`, 'error'); + } + }) + .catch(err => { + btn.disabled = false; + btn.textContent = original; + showFlockToast(`Clear failed: ${err.message}`, 'error'); + }); + } + function flockClearPrev() { flockClearSession('prev'); } + function flockClearLive() { flockClearSession('live'); } + function connectGps() { const port = document.getElementById('gpsPortSelect').value; if (!port) { @@ -1869,6 +2080,7 @@ if (data.flock_connected) { document.getElementById('connectFlockBtn').style.display = 'none'; document.getElementById('disconnectFlockBtn').style.display = 'inline-block'; + setFlockExtraControls(true); } if (data.gps_connected) { @@ -1992,14 +2204,21 @@ gpsLink = `${lat.toFixed(4)}, ${lon.toFixed(4)}`; } + const isReplay = detection.replay === true || detection.timestamp_source === 'device_replay'; + const replaySource = detection.replay_source || (isReplay ? 'device' : null); + const replayBadge = isReplay + ? `${replaySource === 'live' ? 'RAM' : 'FLASH'}` + : ''; + return ` -
+
${detection.detection_method ? detection.detection_method.toUpperCase() : 'UNKNOWN'} ${count}× ${detection.gps && detection.gps.latitude !== undefined ? 'GPS' : ''} + ${replayBadge}
${gpsLink}
@@ -2224,6 +2443,51 @@ const disconnectBtn = document.getElementById('disconnectFlockBtn'); if (connectBtn) connectBtn.style.display = 'inline-block'; if (disconnectBtn) disconnectBtn.style.display = 'none'; + setFlockExtraControls(false); + }); + + // ============================================================ + // Server-side command protocol events (from add_replay_detection + // and handle_command_event in flockyou.py). + // ============================================================ + socket.on('replay_detection', function(detection) { + // Treat replayed detections like new ones for the in-memory + // list, but flag them so renderDetections shows the badge. + if (!detections.find(d => d.id === detection.id)) { + detections.push(detection); + cumulativeDetections.push(detection); + } + updateStats(); + renderDetections(); + }); + + socket.on('flock_replay_complete', function(info) { + const reason = info && info.reason ? ` (${info.reason})` : ''; + const src = info && info.source ? info.source : 'device'; + const ok = info && info.ok; + if (ok) { + showFlockToast(`Replay complete: ${info.count || 0} entries from ${src}${reason}`, + (info.count || 0) > 0 ? 'success' : 'warning'); + } else { + showFlockToast(`Replay failed${reason}`, 'error'); + } + }); + + socket.on('flock_status', function(info) { + // The /api/flock/status REST call also gets this payload via the + // synchronous response. The socket event is for any other client + // tabs that should reflect the same state — no popup needed. + console.log('Flock status broadcast:', info); + }); + + socket.on('flock_clear', function(info) { + console.log('Flock clear broadcast:', info); + }); + + socket.on('flock_error', function(info) { + const reason = (info && info.reason) || 'unknown'; + const cmd = (info && info.cmd) ? ` (${info.cmd})` : ''; + showFlockToast(`Device error: ${reason}${cmd}`, 'error'); }); socket.on('detections_cleared', function() {