494 lines
17 KiB
HTML
494 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>IQ Spectrum Analyzer</title>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
margin: 0; padding: 20px;
|
||
background: #1a1a2e; color: #eee;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
h1 {
|
||
color: #00d4ff;
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
font-size: 1.8em;
|
||
}
|
||
|
||
/* ---- Controls ---- */
|
||
.controls {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
margin: 15px 0;
|
||
}
|
||
|
||
button {
|
||
padding: 10px 28px;
|
||
cursor: pointer;
|
||
background: #00d4ff;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
}
|
||
button:hover { background: #33e0ff; transform: translateY(-1px); }
|
||
button:active { transform: translateY(0); }
|
||
button.stop-btn { background: #ff6b6b; color: #fff; }
|
||
button.stop-btn:hover { background: #ff8a8a; }
|
||
button.pause-btn { background: #ffd93d; color: #1a1a2e; }
|
||
button.pause-btn:hover { background: #ffe066; }
|
||
|
||
.stream-status {
|
||
text-align: center;
|
||
padding: 6px 16px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
display: inline-block;
|
||
margin: 0 auto;
|
||
}
|
||
.status-running { background: rgba(0,212,255,0.15); color: #00d4ff; border: 1px solid rgba(0,212,255,0.3); }
|
||
.status-stopped { background: rgba(255,107,107,0.15); color: #ff6b6b; border: 1px solid rgba(255,107,107,0.3); }
|
||
.status-paused { background: rgba(255,217,61,0.15); color: #ffd93d; border: 1px solid rgba(255,217,61,0.3); }
|
||
|
||
/* ---- Stats bar ---- */
|
||
.stats-bar {
|
||
background: #16213e;
|
||
padding: 14px 20px;
|
||
border-radius: 10px;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 10px;
|
||
margin: 15px 0;
|
||
border: 1px solid #1e3050;
|
||
}
|
||
|
||
.stat-item { text-align: center; }
|
||
.stat-value {
|
||
color: #00d4ff;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.stat-value.good { color: #00d4ff; }
|
||
.stat-value.warn { color: #ffd93d; }
|
||
.stat-value.bad { color: #ff6b6b; }
|
||
.stat-label {
|
||
font-size: 11px;
|
||
color: #8899aa;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* ---- PLOT CONTAINER — this is the key fix ---- */
|
||
.plot-container {
|
||
background: #0f0f23;
|
||
padding: 0; /* NO padding so image fills fully */
|
||
border-radius: 10px;
|
||
margin: 15px 0;
|
||
border: 1px solid #1e3050;
|
||
overflow: hidden; /* clip any overflow */
|
||
line-height: 0; /* remove gap below inline img */
|
||
}
|
||
|
||
#plot {
|
||
width: 100%; /* fill container width */
|
||
height: auto; /* maintain aspect ratio */
|
||
display: block; /* remove inline spacing */
|
||
border-radius: 10px;
|
||
}
|
||
|
||
/* ---- Parameters panel ---- */
|
||
.params-panel {
|
||
background: #16213e;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
margin: 15px 0;
|
||
border: 1px solid #1e3050;
|
||
}
|
||
|
||
.params-panel h3 {
|
||
color: #00d4ff;
|
||
margin-bottom: 15px;
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
.param-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.param-grid label {
|
||
font-size: 12px;
|
||
color: #8899aa;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.param-grid input {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
background: #0f0f23;
|
||
border: 1px solid #2a3a5e;
|
||
border-radius: 6px;
|
||
color: #eee;
|
||
font-size: 14px;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.param-grid input:focus {
|
||
outline: none;
|
||
border-color: #00d4ff;
|
||
box-shadow: 0 0 0 2px rgba(0,212,255,0.15);
|
||
}
|
||
|
||
.apply-btn-wrapper {
|
||
text-align: center;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
/* ---- Gain panel ---- */
|
||
.gain-panel {
|
||
background: #16213e;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
margin: 15px 0;
|
||
border: 1px solid #1e3050;
|
||
}
|
||
.gain-panel h3 {
|
||
color: #00d4ff;
|
||
margin-bottom: 15px;
|
||
font-size: 1.1em;
|
||
}
|
||
.gain-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.gain-grid label {
|
||
font-size: 12px;
|
||
color: #8899aa;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
.gain-grid input {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
background: #0f0f23;
|
||
border: 1px solid #2a3a5e;
|
||
border-radius: 6px;
|
||
color: #eee;
|
||
font-size: 14px;
|
||
}
|
||
.gain-grid input:focus {
|
||
outline: none;
|
||
border-color: #00d4ff;
|
||
}
|
||
|
||
/* ---- Footer ---- */
|
||
.footer {
|
||
text-align: center;
|
||
color: #445;
|
||
font-size: 12px;
|
||
margin-top: 20px;
|
||
padding: 10px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>📡 IQ Spectrum Analyzer</h1>
|
||
|
||
<!-- Stream controls -->
|
||
<div class="controls">
|
||
<button onclick="startStream()" id="startBtn">▶ Start</button>
|
||
<button class="pause-btn" onclick="pauseStream()" id="pauseBtn">⏸ Pause</button>
|
||
<button class="stop-btn" onclick="stopStream()" id="stopBtn">⏹ Stop</button>
|
||
</div>
|
||
<div style="text-align:center; margin-bottom:10px;">
|
||
<span class="stream-status status-stopped" id="streamStatus">● Stopped</span>
|
||
</div>
|
||
|
||
<!-- Stats bar -->
|
||
<div class="stats-bar">
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="slot">--</div>
|
||
<div class="stat-label">Current Slot</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="direction">--</div>
|
||
<div class="stat-label">Direction</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="power">--</div>
|
||
<div class="stat-label">Power (dB)</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="rbs">0</div>
|
||
<div class="stat-label">Allocated RBs</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="packets">0</div>
|
||
<div class="stat-label">IQ Packets</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="slots">0</div>
|
||
<div class="stat-label">Slots Correlated</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="correlation">0%</div>
|
||
<div class="stat-label">Correlation</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="slotRate">0</div>
|
||
<div class="stat-label">Slots/s</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="bandwidth">0</div>
|
||
<div class="stat-label">IQ BW (Mbps)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plot — no padding, image fills container -->
|
||
<div class="plot-container">
|
||
<img id="plot" src="/plot" alt="IQ Spectrum Plot">
|
||
</div>
|
||
|
||
<!-- Parameters -->
|
||
<div class="params-panel">
|
||
<h3>⚙ Spectrogram Parameters</h3>
|
||
<div class="param-grid">
|
||
<div>
|
||
<label>Center Freq (Hz)</label>
|
||
<input type="number" id="center_freq" value="3415000000" step="1000000">
|
||
</div>
|
||
<div>
|
||
<label>Sample Rate (Hz)</label>
|
||
<input type="number" id="sample_rate" value="23040000" step="1000000">
|
||
</div>
|
||
<div>
|
||
<label>FFT Size</label>
|
||
<input type="number" id="fft_size" value="1024" step="128">
|
||
</div>
|
||
<div>
|
||
<label>Window (ms)</label>
|
||
<input type="number" id="window_ms" value="20" step="1">
|
||
</div>
|
||
</div>
|
||
<div class="apply-btn-wrapper">
|
||
<button onclick="updateParams()">⚡ Apply Parameters</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gains -->
|
||
<div class="gain-panel">
|
||
<h3>🎚 Gain Settings</h3>
|
||
<div class="gain-grid">
|
||
<div>
|
||
<label>USRP Tx Gain</label>
|
||
<input type="number" id="usrp_tx_gain" value="60">
|
||
</div>
|
||
<div>
|
||
<label>USRP Rx Gain</label>
|
||
<input type="number" id="usrp_rx_gain" value="30">
|
||
</div>
|
||
<div>
|
||
<label>SCM Tx Gain</label>
|
||
<input type="number" id="scm_tx_gain" value="30">
|
||
</div>
|
||
<div>
|
||
<label>SCM Rx Gain</label>
|
||
<input type="number" id="scm_rx_gain" value="30">
|
||
</div>
|
||
</div>
|
||
<div class="apply-btn-wrapper">
|
||
<button onclick="updateGains()">🔄 Update Gains</button>
|
||
<button onclick="loadGains()" style="background:#2a3a5e; color:#eee; margin-left:8px;">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
IQ Spectrum Analyzer • Real-time RF monitoring with IQ–metadata correlation
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
const socket = io();
|
||
const plotImg = document.getElementById('plot');
|
||
let isStreaming = false;
|
||
|
||
// ---- WebSocket: real-time plot push ----
|
||
socket.on('plot_update', function(data) {
|
||
if (data && data.image) {
|
||
plotImg.src = 'data:image/png;base64,' + data.image;
|
||
}
|
||
});
|
||
|
||
// ---- Fallback: poll /plot if websocket image stale ----
|
||
// (The websocket push is primary; this is backup)
|
||
setInterval(function() {
|
||
if (isStreaming && !document.hidden) {
|
||
// Only use polling as fallback — websocket handles most updates
|
||
// Uncomment below if websocket is unreliable:
|
||
// plotImg.src = '/plot?_ts=' + Date.now();
|
||
}
|
||
}, 2000);
|
||
|
||
// ---- Stream controls ----
|
||
window.startStream = function() {
|
||
fetch('/start_stream', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(d => { if (d.status === 'success') updateStatus('running'); });
|
||
};
|
||
|
||
window.stopStream = function() {
|
||
fetch('/stop_stream', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(d => { if (d.status === 'success') updateStatus('stopped'); });
|
||
};
|
||
|
||
window.pauseStream = function() {
|
||
fetch('/pause_stream', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(d => { if (d.status === 'success') updateStatus(d.state); });
|
||
};
|
||
|
||
function updateStatus(state) {
|
||
const el = document.getElementById('streamStatus');
|
||
el.className = 'stream-status';
|
||
switch(state) {
|
||
case 'running':
|
||
el.className += ' status-running';
|
||
el.textContent = '● Streaming';
|
||
isStreaming = true;
|
||
break;
|
||
case 'paused':
|
||
el.className += ' status-paused';
|
||
el.textContent = '● Paused';
|
||
isStreaming = true;
|
||
break;
|
||
default:
|
||
el.className += ' status-stopped';
|
||
el.textContent = '● Stopped';
|
||
isStreaming = false;
|
||
}
|
||
}
|
||
|
||
// ---- Stats polling ----
|
||
setInterval(function() {
|
||
fetch('/get_stats')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
document.getElementById('slot').textContent = d.current_slot;
|
||
document.getElementById('direction').textContent = d.direction || '--';
|
||
document.getElementById('power').textContent =
|
||
d.power_db !== null ? d.power_db.toFixed(1) : '--';
|
||
document.getElementById('rbs').textContent = d.allocated_rbs || 0;
|
||
document.getElementById('packets').textContent =
|
||
d.packets_received.toLocaleString();
|
||
document.getElementById('slots').textContent =
|
||
d.slots_correlated.toLocaleString();
|
||
|
||
// Correlation rate with color coding
|
||
const corrEl = document.getElementById('correlation');
|
||
const corrVal = d.correlation_rate || 0;
|
||
corrEl.textContent = corrVal.toFixed(1) + '%';
|
||
corrEl.className = 'stat-value ' +
|
||
(corrVal > 90 ? 'good' : corrVal > 50 ? 'warn' : 'bad');
|
||
|
||
document.getElementById('slotRate').textContent =
|
||
(d.slot_rate || 0).toFixed(1);
|
||
document.getElementById('bandwidth').textContent =
|
||
(d.iq_bandwidth_mbps || 0).toFixed(1);
|
||
})
|
||
.catch(() => {});
|
||
}, 1000);
|
||
|
||
// ---- Stream state polling ----
|
||
setInterval(function() {
|
||
fetch('/get_stream_state')
|
||
.then(r => r.json())
|
||
.then(d => updateStatus(d.state))
|
||
.catch(() => {});
|
||
}, 3000);
|
||
|
||
// ---- Parameters ----
|
||
window.updateParams = function() {
|
||
const formData = new FormData();
|
||
['center_freq', 'sample_rate', 'fft_size', 'window_ms'].forEach(id => {
|
||
formData.append(id, document.getElementById(id).value);
|
||
});
|
||
fetch('/update_params', {method: 'POST', body: formData})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.status === 'success') {
|
||
showToast('Parameters updated');
|
||
}
|
||
});
|
||
};
|
||
|
||
// ---- Gains ----
|
||
window.updateGains = function() {
|
||
const formData = new FormData();
|
||
['usrp_tx_gain', 'usrp_rx_gain', 'scm_tx_gain', 'scm_rx_gain'].forEach(id => {
|
||
formData.append(id, document.getElementById(id).value);
|
||
});
|
||
fetch('/update_gains', {method: 'POST', body: formData})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.status === 'success') showToast('Gains updated');
|
||
});
|
||
};
|
||
|
||
window.loadGains = function() {
|
||
fetch('/get_gains')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.usrp_tx_gain !== undefined)
|
||
document.getElementById('usrp_tx_gain').value = d.usrp_tx_gain;
|
||
if (d.usrp_rx_gain !== undefined)
|
||
document.getElementById('usrp_rx_gain').value = d.usrp_rx_gain;
|
||
if (d.scm_tx_gain !== undefined)
|
||
document.getElementById('scm_tx_gain').value = d.scm_tx_gain;
|
||
if (d.scm_rx_gain !== undefined)
|
||
document.getElementById('scm_rx_gain').value = d.scm_rx_gain;
|
||
showToast('Gains refreshed');
|
||
});
|
||
};
|
||
|
||
// ---- Toast notification ----
|
||
function showToast(msg) {
|
||
let toast = document.createElement('div');
|
||
toast.textContent = msg;
|
||
toast.style.cssText =
|
||
'position:fixed; bottom:30px; left:50%; transform:translateX(-50%);' +
|
||
'background:#00d4ff; color:#1a1a2e; padding:10px 24px; border-radius:8px;' +
|
||
'font-weight:bold; font-size:14px; z-index:9999; opacity:0;' +
|
||
'transition:opacity 0.3s;';
|
||
document.body.appendChild(toast);
|
||
requestAnimationFrame(() => toast.style.opacity = '1');
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 2000);
|
||
}
|
||
|
||
// ---- Init ----
|
||
loadGains();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |