mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 14:50:00 -07:00
Add alerts/recording, WiFi/TSCM updates, optimize waterfall
This commit is contained in:
194
static/js/core/alerts.js
Normal file
194
static/js/core/alerts.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const AlertCenter = (function() {
|
||||
'use strict';
|
||||
|
||||
let alerts = [];
|
||||
let rules = [];
|
||||
let eventSource = null;
|
||||
|
||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
loadFeed();
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
eventSource = new EventSource('/alerts/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'keepalive') return;
|
||||
handleAlert(data);
|
||||
} catch (err) {
|
||||
console.error('[Alerts] SSE parse error', err);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[Alerts] SSE connection error');
|
||||
};
|
||||
}
|
||||
|
||||
function handleAlert(alert) {
|
||||
alerts.unshift(alert);
|
||||
alerts = alerts.slice(0, 50);
|
||||
updateFeedUI();
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
const severity = (alert.severity || '').toLowerCase();
|
||||
if (['high', 'critical'].includes(severity)) {
|
||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFeedUI() {
|
||||
const list = document.getElementById('alertsFeedList');
|
||||
const countEl = document.getElementById('alertsFeedCount');
|
||||
if (countEl) countEl.textContent = `(${alerts.length})`;
|
||||
if (!list) return;
|
||||
|
||||
if (alerts.length === 0) {
|
||||
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = alerts.map(alert => {
|
||||
const title = escapeHtml(alert.title || 'Alert');
|
||||
const message = escapeHtml(alert.message || '');
|
||||
const severity = escapeHtml(alert.severity || 'medium');
|
||||
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
|
||||
return `
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-title">
|
||||
<span>${title}</span>
|
||||
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="settings-feed-meta">${message}</div>
|
||||
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadFeed() {
|
||||
fetch('/alerts/events?limit=20')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alerts = data.events || [];
|
||||
updateFeedUI();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
fetch('/alerts/rules?all=1')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
rules = data.rules || [];
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||
}
|
||||
|
||||
function enableTrackerAlerts() {
|
||||
ensureTrackerRule(true);
|
||||
}
|
||||
|
||||
function disableTrackerAlerts() {
|
||||
ensureTrackerRule(false);
|
||||
}
|
||||
|
||||
function ensureTrackerRule(enabled) {
|
||||
loadRules();
|
||||
setTimeout(() => {
|
||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||
if (existing) {
|
||||
fetch(`/alerts/rules/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
}).then(() => loadRules());
|
||||
} else if (enabled) {
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: TRACKER_RULE_NAME,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { is_tracker: true },
|
||||
severity: 'high',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function addBluetoothWatchlist(address, name) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: address },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
|
||||
function removeBluetoothWatchlist(address) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
if (!existing) return;
|
||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules());
|
||||
}
|
||||
|
||||
function isWatchlisted(address) {
|
||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
loadFeed,
|
||||
enableTrackerAlerts,
|
||||
disableTrackerAlerts,
|
||||
addBluetoothWatchlist,
|
||||
removeBluetoothWatchlist,
|
||||
isWatchlisted,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.init();
|
||||
}
|
||||
});
|
||||
136
static/js/core/recordings.js
Normal file
136
static/js/core/recordings.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const RecordingUI = (function() {
|
||||
'use strict';
|
||||
|
||||
let recordings = [];
|
||||
let active = [];
|
||||
|
||||
function init() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetch('/recordings')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
recordings = data.recordings || [];
|
||||
active = data.active || [];
|
||||
renderActive();
|
||||
renderRecordings();
|
||||
})
|
||||
.catch(err => console.error('[Recording] Load failed', err));
|
||||
}
|
||||
|
||||
function start() {
|
||||
const modeSelect = document.getElementById('recordingModeSelect');
|
||||
const labelInput = document.getElementById('recordingLabelInput');
|
||||
const mode = modeSelect ? modeSelect.value : '';
|
||||
const label = labelInput ? labelInput.value : '';
|
||||
if (!mode) return;
|
||||
|
||||
fetch('/recordings/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode, label })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
refresh();
|
||||
})
|
||||
.catch(err => console.error('[Recording] Start failed', err));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
const modeSelect = document.getElementById('recordingModeSelect');
|
||||
const mode = modeSelect ? modeSelect.value : '';
|
||||
if (!mode) return;
|
||||
|
||||
fetch('/recordings/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh())
|
||||
.catch(err => console.error('[Recording] Stop failed', err));
|
||||
}
|
||||
|
||||
function stopById(sessionId) {
|
||||
fetch('/recordings/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: sessionId })
|
||||
}).then(() => refresh());
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
const container = document.getElementById('recordingActiveList');
|
||||
if (!container) return;
|
||||
if (!active.length) {
|
||||
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = active.map(session => {
|
||||
return `
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-title">
|
||||
<span>${escapeHtml(session.mode)}</span>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
|
||||
</div>
|
||||
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
|
||||
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRecordings() {
|
||||
const container = document.getElementById('recordingList');
|
||||
if (!container) return;
|
||||
if (!recordings.length) {
|
||||
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = recordings.map(rec => {
|
||||
return `
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-title">
|
||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||
</div>
|
||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function download(sessionId) {
|
||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
start,
|
||||
stop,
|
||||
stopById,
|
||||
download,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.init();
|
||||
}
|
||||
});
|
||||
@@ -922,5 +922,13 @@ function switchSettingsTab(tabName) {
|
||||
loadUpdateStatus();
|
||||
} else if (tabName === 'location') {
|
||||
loadObserverLocation();
|
||||
} else if (tabName === 'alerts') {
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.loadFeed();
|
||||
}
|
||||
} else if (tabName === 'recording') {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
|
||||
// Badges
|
||||
const badgesEl = document.getElementById('btDetailBadges');
|
||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
if (device.seen_before) {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||
}
|
||||
|
||||
// Tracker badge
|
||||
if (device.is_tracker) {
|
||||
@@ -448,12 +451,14 @@ const BluetoothMode = (function() {
|
||||
? minMax[0] + '/' + minMax[1]
|
||||
: '--';
|
||||
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
@@ -465,13 +470,29 @@ const BluetoothMode = (function() {
|
||||
servicesContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
|
||||
// Highlight selected device in list
|
||||
highlightSelectedDevice(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist button state
|
||||
*/
|
||||
function updateWatchlistButton(device) {
|
||||
const btn = document.getElementById('btDetailWatchBtn');
|
||||
if (!btn) return;
|
||||
if (typeof AlertCenter === 'undefined') {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.style.display = '';
|
||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||
btn.classList.toggle('active', watchlisted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device selection
|
||||
@@ -525,24 +546,43 @@ const BluetoothMode = (function() {
|
||||
/**
|
||||
* Copy selected device address to clipboard
|
||||
*/
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.querySelector('.bt-detail-btn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.getElementById('btDetailCopyBtn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '';
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Bluetooth watchlist for selected device
|
||||
*/
|
||||
function toggleWatchlist() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device || typeof AlertCenter === 'undefined') return;
|
||||
|
||||
if (AlertCenter.isWatchlisted(device.address)) {
|
||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||
showInfo('Removed from watchlist');
|
||||
} else {
|
||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||
showInfo('Added to watchlist');
|
||||
}
|
||||
|
||||
setTimeout(() => updateWatchlistButton(device), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device - opens modal with details
|
||||
@@ -1090,10 +1130,11 @@ const BluetoothMode = (function() {
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
if (agentName !== 'Local') {
|
||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
@@ -3021,15 +3021,23 @@ let spectrumCanvas = null;
|
||||
let spectrumCtx = null;
|
||||
let waterfallStartFreq = 88;
|
||||
let waterfallEndFreq = 108;
|
||||
let waterfallRowImage = null;
|
||||
let waterfallPalette = null;
|
||||
let lastWaterfallDraw = 0;
|
||||
const WATERFALL_MIN_INTERVAL_MS = 80;
|
||||
|
||||
function initWaterfallCanvas() {
|
||||
waterfallCanvas = document.getElementById('waterfallCanvas');
|
||||
spectrumCanvas = document.getElementById('spectrumCanvas');
|
||||
if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d');
|
||||
if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d');
|
||||
if (waterfallCtx && waterfallCanvas) {
|
||||
waterfallRowImage = waterfallCtx.createImageData(waterfallCanvas.width, 1);
|
||||
if (!waterfallPalette) waterfallPalette = buildWaterfallPalette();
|
||||
}
|
||||
}
|
||||
|
||||
function dBmToColor(normalized) {
|
||||
function dBmToRgb(normalized) {
|
||||
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
||||
const n = Math.max(0, Math.min(1, normalized));
|
||||
let r, g, b;
|
||||
@@ -3054,7 +3062,15 @@ function dBmToColor(normalized) {
|
||||
g = Math.round(255 - t * 55);
|
||||
b = Math.round(20 - t * 20);
|
||||
}
|
||||
return `rgb(${r},${g},${b})`;
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function buildWaterfallPalette() {
|
||||
const palette = new Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
palette[i] = dBmToRgb(i / 255);
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function drawWaterfallRow(bins) {
|
||||
@@ -3062,9 +3078,8 @@ function drawWaterfallRow(bins) {
|
||||
const w = waterfallCanvas.width;
|
||||
const h = waterfallCanvas.height;
|
||||
|
||||
// Scroll existing content down by 1 pixel
|
||||
const imageData = waterfallCtx.getImageData(0, 0, w, h - 1);
|
||||
waterfallCtx.putImageData(imageData, 0, 1);
|
||||
// Scroll existing content down by 1 pixel (GPU-accelerated)
|
||||
waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||
|
||||
// Find min/max for normalization
|
||||
let minVal = Infinity, maxVal = -Infinity;
|
||||
@@ -3074,13 +3089,24 @@ function drawWaterfallRow(bins) {
|
||||
}
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Draw new row at top
|
||||
const binWidth = w / bins.length;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const normalized = (bins[i] - minVal) / range;
|
||||
waterfallCtx.fillStyle = dBmToColor(normalized);
|
||||
waterfallCtx.fillRect(Math.floor(i * binWidth), 0, Math.ceil(binWidth) + 1, 1);
|
||||
// Draw new row at top using ImageData
|
||||
if (!waterfallRowImage || waterfallRowImage.width !== w) {
|
||||
waterfallRowImage = waterfallCtx.createImageData(w, 1);
|
||||
}
|
||||
const rowData = waterfallRowImage.data;
|
||||
const palette = waterfallPalette || buildWaterfallPalette();
|
||||
const binCount = bins.length;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = Math.min(binCount - 1, Math.floor((x / w) * binCount));
|
||||
const normalized = (bins[idx] - minVal) / range;
|
||||
const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0];
|
||||
const offset = x * 4;
|
||||
rowData[offset] = color[0];
|
||||
rowData[offset + 1] = color[1];
|
||||
rowData[offset + 2] = color[2];
|
||||
rowData[offset + 3] = 255;
|
||||
}
|
||||
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
||||
}
|
||||
|
||||
function drawSpectrumLine(bins, startFreq, endFreq) {
|
||||
@@ -3154,6 +3180,7 @@ function startWaterfall() {
|
||||
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
||||
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
const maxBins = document.getElementById('waterfallCanvas')?.width || 800;
|
||||
|
||||
if (startFreq >= endFreq) {
|
||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
||||
@@ -3166,7 +3193,14 @@ function startWaterfall() {
|
||||
fetch('/listening/waterfall/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_freq: startFreq, end_freq: endFreq, bin_size: binSize, gain: gain, device: device })
|
||||
body: JSON.stringify({
|
||||
start_freq: startFreq,
|
||||
end_freq: endFreq,
|
||||
bin_size: binSize,
|
||||
gain: gain,
|
||||
device: device,
|
||||
max_bins: maxBins,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -3176,6 +3210,7 @@ function startWaterfall() {
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
lastWaterfallDraw = 0;
|
||||
initWaterfallCanvas();
|
||||
connectWaterfallSSE();
|
||||
} else {
|
||||
@@ -3204,6 +3239,9 @@ function connectWaterfallSSE() {
|
||||
waterfallEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'waterfall_sweep') {
|
||||
const now = Date.now();
|
||||
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||
lastWaterfallDraw = now;
|
||||
drawWaterfallRow(msg.bins);
|
||||
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
||||
maxProbes: 1000,
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
|
||||
/**
|
||||
* Check for agent mode conflicts before starting WiFi scan.
|
||||
*/
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getChannelPresetList(preset) {
|
||||
switch (preset) {
|
||||
case '2.4-common':
|
||||
return '1,6,11';
|
||||
case '2.4-all':
|
||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||
case '5-low':
|
||||
return '36,40,44,48';
|
||||
case '5-mid':
|
||||
return '52,56,60,64';
|
||||
case '5-high':
|
||||
return '149,153,157,161,165';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelConfig() {
|
||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||
|
||||
const listValue = listInput.trim();
|
||||
const presetValue = getChannelPresetList(preset);
|
||||
|
||||
const channels = listValue || presetValue || '';
|
||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||
|
||||
return {
|
||||
channels: channels || null,
|
||||
channel: Number.isFinite(channel) ? channel : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
@@ -461,10 +495,10 @@ const WiFiMode = (function() {
|
||||
setScanning(true, 'deep');
|
||||
|
||||
try {
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channelConfig = buildChannelConfig();
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
|
||||
let response;
|
||||
if (isAgentMode) {
|
||||
@@ -473,23 +507,25 @@ const WiFiMode = (function() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user