mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-12 15:13:30 -07:00
dashboard: surface the CMD:* protocol in the UI
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).
This commit is contained in:
+266
-2
@@ -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 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="flockToast" role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
@@ -1298,6 +1376,13 @@
|
||||
</div>
|
||||
<button id="connectFlockBtn">Connect</button>
|
||||
<button id="disconnectFlockBtn" class="danger" style="display: none;">Disconnect</button>
|
||||
<div class="device-extra-controls" id="flockExtraControls" style="display: none;">
|
||||
<button class="flock-cmd-btn" id="dumpPrevBtn" title="Pull /prev_session.json from device SPIFFS (CMD:DUMP_PREV)" onclick="flockDumpPrev()">Pull Prev</button>
|
||||
<button class="flock-cmd-btn" id="dumpLiveBtn" title="Pull the device's in-RAM detection table (CMD:DUMP_LIVE)" onclick="flockDumpLive()">Pull Live</button>
|
||||
<button class="flock-cmd-btn" id="flockStatusBtn" title="Query device status (CMD:STATUS)" onclick="flockStatusQuery()">Status</button>
|
||||
<button class="flock-cmd-btn danger" id="clearPrevBtn" title="Delete /prev_session.json on the device (CMD:CLEAR_PREV)" onclick="flockClearPrev()">Clear Prev</button>
|
||||
<button class="flock-cmd-btn danger" id="clearLiveBtn" title="Wipe the device's in-RAM detection table (CMD:CLEAR_LIVE)" onclick="flockClearLive()">Clear Live</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="device-control-group">
|
||||
@@ -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 = `<a href="${osmUrl}" target="_blank" class="gps-link">${lat.toFixed(4)}, ${lon.toFixed(4)}</a>`;
|
||||
}
|
||||
|
||||
const isReplay = detection.replay === true || detection.timestamp_source === 'device_replay';
|
||||
const replaySource = detection.replay_source || (isReplay ? 'device' : null);
|
||||
const replayBadge = isReplay
|
||||
? `<span class="replay-badge ${replaySource === 'live' ? 'live' : ''}" title="From device memory${replaySource ? ' (' + replaySource + ')' : ''}">${replaySource === 'live' ? 'RAM' : 'FLASH'}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="detection-item">
|
||||
<div class="detection-item${isReplay ? ' replay' : ''}">
|
||||
<div class="detection-header">
|
||||
<div class="detection-header-left">
|
||||
<div class="detection-type-badge">
|
||||
<span class="detection-type">${detection.detection_method ? detection.detection_method.toUpperCase() : 'UNKNOWN'}</span>
|
||||
<span class="detection-count">${count}×</span>
|
||||
${detection.gps && detection.gps.latitude !== undefined ? '<span class="gps-tag">GPS</span>' : ''}
|
||||
${replayBadge}
|
||||
</div>
|
||||
${gpsLink}
|
||||
</div>
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user