gain-viz/templates/index.html

494 lines
17 KiB
HTML
Raw Normal View History

<!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 IQmetadata 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>