- Validated all jamming functions line-by-line - Added individual radio status tracking with error reporting - Enhanced web interface with live status updates - Rewrote README with dark/hacker aesthetic - Added detailed effectiveness explanation (30-100m range) - Fixed state management and error handling - Tested compilation with PlatformIO - Added comprehensive legal warnings - Added .gitignore for build artifacts
498 lines
13 KiB
HTML
498 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Embedded Device Dashboard</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0b1020;
|
|
--panel: #121a2f;
|
|
--panel-2: #0f1629;
|
|
--text: #e8edf7;
|
|
--muted: #a5b4cf;
|
|
--border: #283555;
|
|
--accent: #4f8cff;
|
|
--accent-2: #7aa6ff;
|
|
--good: #1f9d55;
|
|
--warn: #c78b19;
|
|
--bad: #d14343;
|
|
--shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
|
--radius: 12px;
|
|
--radius-sm: 8px;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html, body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background: radial-gradient(1200px 700px at 20% -10%, #1a2550 0%, var(--bg) 50%);
|
|
color: var(--text);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
min-height: 100%;
|
|
}
|
|
|
|
.app {
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.35rem;
|
|
margin: 0;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
|
|
.sub {
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--muted);
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: var(--warn);
|
|
box-shadow: 0 0 0 5px rgba(199, 139, 25, 0.18);
|
|
transition: all 180ms ease;
|
|
}
|
|
|
|
.dot.ok {
|
|
background: var(--good);
|
|
box-shadow: 0 0 0 5px rgba(31, 157, 85, 0.2);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.card {
|
|
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
padding: 16px;
|
|
}
|
|
|
|
.card h2 {
|
|
margin: 0 0 12px;
|
|
font-size: 1rem;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.metrics {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
.metric {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 12px;
|
|
}
|
|
|
|
.metric .label {
|
|
color: var(--muted);
|
|
font-size: 0.82rem;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.metric .value {
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
|
|
.controls {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.field label {
|
|
font-size: 0.9rem;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
input[type="range"] {
|
|
width: 100%;
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
button {
|
|
appearance: none;
|
|
border: 1px solid transparent;
|
|
border-radius: 10px;
|
|
padding: 10px 14px;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
color: #fff;
|
|
background: var(--accent);
|
|
transition: transform 100ms ease, filter 150ms ease, border-color 150ms ease;
|
|
}
|
|
|
|
button:hover {
|
|
filter: brightness(1.06);
|
|
}
|
|
|
|
button:active {
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
button.secondary {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-color: var(--border);
|
|
color: var(--text);
|
|
}
|
|
|
|
button.critical {
|
|
background: var(--bad);
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
button:focus-visible,
|
|
input:focus-visible {
|
|
outline: 2px solid var(--accent-2);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-radius: 999px;
|
|
padding: 7px 11px;
|
|
border: 1px solid var(--border);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
font-size: 0.85rem;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
pre {
|
|
margin: 0;
|
|
min-height: 280px;
|
|
max-height: 420px;
|
|
overflow: auto;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border);
|
|
background: #0c1222;
|
|
padding: 12px;
|
|
line-height: 1.4;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
font-size: 0.8rem;
|
|
color: #d9e5ff;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 14px;
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
text-align: right;
|
|
}
|
|
|
|
.toasts {
|
|
position: fixed;
|
|
right: 14px;
|
|
bottom: 14px;
|
|
display: grid;
|
|
gap: 8px;
|
|
z-index: 50;
|
|
width: min(360px, calc(100vw - 28px));
|
|
}
|
|
|
|
.toast {
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border);
|
|
background: #111b33;
|
|
color: var(--text);
|
|
padding: 10px 12px;
|
|
box-shadow: var(--shadow);
|
|
font-size: 0.88rem;
|
|
animation: slideIn 140ms ease;
|
|
}
|
|
|
|
.toast.ok { border-left: 4px solid var(--good); }
|
|
.toast.warn { border-left: 4px solid var(--warn); }
|
|
.toast.err { border-left: 4px solid var(--bad); }
|
|
|
|
@keyframes slideIn {
|
|
from { transform: translateY(8px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="topbar">
|
|
<div>
|
|
<h1>Embedded Device Dashboard</h1>
|
|
<div class="sub">Polished, responsive, and API-ready web interface</div>
|
|
</div>
|
|
<div class="chip">
|
|
<span id="onlineDot" class="dot"></span>
|
|
<span id="onlineText">Connecting...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<h2>Live Status</h2>
|
|
<div id="statePill" class="status-pill">
|
|
<span class="dot" id="stateDot"></span>
|
|
<span id="stateText">Unknown</span>
|
|
</div>
|
|
<div class="metrics">
|
|
<div class="metric">
|
|
<div class="label">Uptime</div>
|
|
<div class="value" id="uptime">--</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Free Heap</div>
|
|
<div class="value" id="heap">--</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Power</div>
|
|
<div class="value" id="powerMetric">--</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Controls</h2>
|
|
<div class="controls">
|
|
<div class="field">
|
|
<label for="powerSlider">Power Level: <span id="powerLabel">10</span></label>
|
|
<input id="powerSlider" type="range" min="0" max="10" step="1" value="10">
|
|
</div>
|
|
<div class="row">
|
|
<button id="toggleBtn">Toggle</button>
|
|
<button id="applyBtn" class="secondary">Apply Settings</button>
|
|
<button id="refreshBtn" class="secondary">Refresh</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="card" style="margin-top: 16px;">
|
|
<h2>System Log</h2>
|
|
<pre id="logView">Loading logs...</pre>
|
|
<div class="footer">Auto-refresh every 1 second</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="true"></div>
|
|
|
|
<script>
|
|
const ui = {
|
|
onlineDot: document.getElementById("onlineDot"),
|
|
onlineText: document.getElementById("onlineText"),
|
|
stateDot: document.getElementById("stateDot"),
|
|
stateText: document.getElementById("stateText"),
|
|
uptime: document.getElementById("uptime"),
|
|
heap: document.getElementById("heap"),
|
|
powerMetric: document.getElementById("powerMetric"),
|
|
powerSlider: document.getElementById("powerSlider"),
|
|
powerLabel: document.getElementById("powerLabel"),
|
|
toggleBtn: document.getElementById("toggleBtn"),
|
|
applyBtn: document.getElementById("applyBtn"),
|
|
refreshBtn: document.getElementById("refreshBtn"),
|
|
logView: document.getElementById("logView"),
|
|
toasts: document.getElementById("toasts")
|
|
};
|
|
|
|
const state = {
|
|
enabled: false,
|
|
pollHandle: null,
|
|
inFlight: false
|
|
};
|
|
|
|
function showToast(message, kind = "ok", timeout = 2600) {
|
|
const el = document.createElement("div");
|
|
el.className = "toast " + kind;
|
|
el.textContent = message;
|
|
ui.toasts.appendChild(el);
|
|
window.setTimeout(() => {
|
|
el.remove();
|
|
}, timeout);
|
|
}
|
|
|
|
function setOnline(online) {
|
|
ui.onlineDot.classList.toggle("ok", online);
|
|
ui.onlineText.textContent = online ? "Device online" : "Device offline";
|
|
}
|
|
|
|
function fmtUptime(ms) {
|
|
const s = Math.max(0, Math.floor(ms / 1000));
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const sec = s % 60;
|
|
if (h > 0) return h + "h " + m + "m " + sec + "s";
|
|
if (m > 0) return m + "m " + sec + "s";
|
|
return sec + "s";
|
|
}
|
|
|
|
function renderStatus(telemetry) {
|
|
state.enabled = Boolean(telemetry.jamming_enabled);
|
|
const active = state.enabled;
|
|
ui.stateDot.classList.toggle("ok", active);
|
|
ui.stateText.textContent = active ? "ACTIVE" : "INACTIVE";
|
|
ui.toggleBtn.textContent = active ? "Stop" : "Start";
|
|
ui.uptime.textContent = fmtUptime(Number(telemetry.uptime_ms || 0));
|
|
ui.heap.textContent = String(telemetry.free_heap ?? "--");
|
|
ui.powerMetric.textContent = String(telemetry.jam_power ?? "--") + " dBm";
|
|
ui.powerSlider.value = String(telemetry.jam_power ?? 0);
|
|
ui.powerLabel.textContent = ui.powerSlider.value;
|
|
}
|
|
|
|
async function fetchJson(path, options) {
|
|
const res = await fetch(path, options);
|
|
if (!res.ok) throw new Error(path + " failed with " + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchText(path, options) {
|
|
const res = await fetch(path, options);
|
|
if (!res.ok) throw new Error(path + " failed with " + res.status);
|
|
return res.text();
|
|
}
|
|
|
|
async function refreshAll(silent = false) {
|
|
if (state.inFlight) return;
|
|
state.inFlight = true;
|
|
try {
|
|
const [telemetry, logs] = await Promise.all([
|
|
fetchJson("/api/telemetry"),
|
|
fetchText("/api/log")
|
|
]);
|
|
renderStatus(telemetry);
|
|
ui.logView.textContent = logs || "(No log entries)";
|
|
ui.logView.scrollTop = ui.logView.scrollHeight;
|
|
setOnline(true);
|
|
} catch (err) {
|
|
setOnline(false);
|
|
if (!silent) showToast("Refresh failed: " + err.message, "err", 3200);
|
|
} finally {
|
|
state.inFlight = false;
|
|
}
|
|
}
|
|
|
|
async function onToggle() {
|
|
ui.toggleBtn.disabled = true;
|
|
try {
|
|
const out = await fetchJson("/api/toggle", { method: "POST" });
|
|
state.enabled = Boolean(out.enabled);
|
|
showToast(state.enabled ? "Started successfully" : "Stopped successfully");
|
|
await refreshAll(true);
|
|
} catch (err) {
|
|
showToast("Toggle failed: " + err.message, "err", 3200);
|
|
} finally {
|
|
ui.toggleBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function onApply() {
|
|
ui.applyBtn.disabled = true;
|
|
try {
|
|
const payload = { power: Number(ui.powerSlider.value) };
|
|
const out = await fetchJson("/api/settings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (out && out.success) {
|
|
showToast("Settings applied");
|
|
await refreshAll(true);
|
|
} else {
|
|
throw new Error("settings response invalid");
|
|
}
|
|
} catch (err) {
|
|
showToast("Apply failed: " + err.message, "err", 3200);
|
|
} finally {
|
|
ui.applyBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
ui.powerSlider.addEventListener("input", () => {
|
|
ui.powerLabel.textContent = ui.powerSlider.value;
|
|
});
|
|
ui.toggleBtn.addEventListener("click", onToggle);
|
|
ui.applyBtn.addEventListener("click", onApply);
|
|
ui.refreshBtn.addEventListener("click", () => refreshAll(false));
|
|
window.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") refreshAll(true);
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
bindEvents();
|
|
await refreshAll(true);
|
|
state.pollHandle = window.setInterval(() => refreshAll(true), 1000);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
showToast("Startup failed: " + err.message, "err", 4000);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|