feat: v0.3.6 - Node settings page, map in sidebar layout

Add /settings page showing device, LoRa, channels, position, power and
bluetooth/network config read from the Meshtastic node. Rebuild map page
with consistent sidebar layout instead of standalone fullscreen view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-15 18:06:32 +01:00
parent 65703b6389
commit d3c3806ed5
11 changed files with 555 additions and 91 deletions

View file

@ -1,5 +1,19 @@
# Changelog
## [0.3.6] - 2026-02-15
### Added
- Node-Einstellungen Seite (`/settings`) zeigt Geraet, LoRa, Channels, Position, Power, Bluetooth/Netzwerk
- Neuer API-Endpoint `GET /api/node/config` liest Config vom lokalen Meshtastic-Node
- `get_node_config()` Methode im Bot (liest localConfig, myInfo, metadata via Protobuf)
- Sidebar-Eintrag "Einstellungen" mit Gear-Icon auf allen Seiten
### Changed
- Karte (`/map`) im Sidebar-Layout statt Vollbild (Top-Navbar, Sidebar, Content-Wrapper)
- Karte oeffnet im selben Tab statt `target="_blank"`
- Status-Info und Node-Count in Karten-Navbar integriert
- Map-Styles (Tooltip, Legende) in zentrale `style.css` verschoben
- Sidebar-Navigation auf allen 4 Seiten konsistent (Dashboard, Scheduler, Karte, Einstellungen)
## [0.3.5] - 2026-02-15
### Changed
- Dashboard und Scheduler auf AdminLTE-Style umgestellt

View file

@ -1,4 +1,4 @@
version: "0.3.5"
version: "0.3.6"
bot:
name: "MeshDD-Bot"

View file

@ -4,6 +4,7 @@ import time
import json
import urllib.request
from google.protobuf.json_format import MessageToDict
from meshtastic.tcp_interface import TCPInterface
from pubsub import pub
@ -44,6 +45,100 @@ class MeshBot:
channels[ch.index] = name
return channels
def get_node_config(self) -> dict:
result = {"device": {}, "lora": {}, "channels": [], "position": {}, "power": {}, "bluetooth": {}, "network": {}}
if not self.interface or not self.interface.localNode:
return result
node = self.interface.localNode
my_info = getattr(self.interface, 'myInfo', None)
metadata = getattr(self.interface, 'metadata', None)
# Device info
if my_info:
info = MessageToDict(my_info) if hasattr(my_info, 'DESCRIPTOR') else (my_info if isinstance(my_info, dict) else {})
result["device"]["node_num"] = info.get("myNodeNum", "")
result["device"]["max_channels"] = info.get("maxChannels", "")
if metadata:
meta = MessageToDict(metadata) if hasattr(metadata, 'DESCRIPTOR') else (metadata if isinstance(metadata, dict) else {})
result["device"]["firmware_version"] = meta.get("firmwareVersion", "")
result["device"]["hw_model"] = meta.get("hwModel", "")
# Local config sections
local_config = getattr(node, 'localConfig', None)
if local_config:
cfg = MessageToDict(local_config, preserving_proto_field_name=True) if hasattr(local_config, 'DESCRIPTOR') else {}
lora = cfg.get("lora", {})
result["lora"] = {
"region": lora.get("region", ""),
"modem_preset": lora.get("modem_preset", ""),
"hop_limit": lora.get("hop_limit", ""),
"tx_power": lora.get("tx_power", ""),
"bandwidth": lora.get("bandwidth", ""),
"frequency_offset": lora.get("frequency_offset", ""),
"tx_enabled": lora.get("tx_enabled", ""),
"use_preset": lora.get("use_preset", ""),
}
device = cfg.get("device", {})
result["device"]["name"] = device.get("role", "")
result["device"]["role"] = device.get("role", "")
pos = cfg.get("position", {})
result["position"] = {
"gps_mode": pos.get("gps_mode", pos.get("gps_enabled", "")),
"position_broadcast_secs": pos.get("position_broadcast_secs", ""),
"fixed_position": pos.get("fixed_position", ""),
}
power = cfg.get("power", {})
result["power"] = {
"mesh_sds_timeout_secs": power.get("mesh_sds_timeout_secs", ""),
"ls_secs": power.get("ls_secs", ""),
"min_wake_secs": power.get("min_wake_secs", ""),
"is_power_saving": power.get("is_power_saving", ""),
}
bt = cfg.get("bluetooth", {})
result["bluetooth"] = {
"enabled": bt.get("enabled", ""),
"mode": bt.get("mode", ""),
}
network = cfg.get("network", {})
result["network"] = {
"wifi_enabled": network.get("wifi_enabled", ""),
"ntp_server": network.get("ntp_server", ""),
}
# Channels
for ch in node.channels:
if ch.role:
name = ch.settings.name if ch.settings.name else ("Primary" if ch.index == 0 else f"Ch {ch.index}")
psk = ch.settings.psk
psk_display = "Default" if psk == b'\x01' else ("Custom" if psk and psk != b'\x00' else "None")
result["channels"].append({
"index": ch.index,
"name": name,
"role": str(ch.role),
"psk": psk_display,
})
# Node long name from interface nodes
if self.interface.nodes:
my_num = getattr(self.interface, 'myInfo', None)
if my_num and hasattr(my_num, 'my_node_num'):
for n in self.interface.nodes.values():
if n.get("num") == my_num.my_node_num:
result["device"]["long_name"] = n.get("user", {}).get("longName", "")
result["device"]["short_name"] = n.get("user", {}).get("shortName", "")
result["device"]["hw_model"] = n.get("user", {}).get("hwModel", result["device"].get("hw_model", ""))
break
return result
def _on_connection(self, interface, topic=pub.AUTO_TOPIC):
logger.info("Meshtastic connection established")
if hasattr(interface, 'nodes') and interface.nodes:

View file

@ -47,6 +47,8 @@ class WebServer:
self.app.router.add_put("/api/scheduler/jobs/{name}", self._api_scheduler_update)
self.app.router.add_delete("/api/scheduler/jobs/{name}", self._api_scheduler_delete)
self.app.router.add_post("/api/send", self._api_send)
self.app.router.add_get("/api/node/config", self._api_node_config)
self.app.router.add_get("/settings", self._serve_settings)
self.app.router.add_get("/scheduler", self._serve_scheduler)
self.app.router.add_get("/map", self._serve_map)
self.app.router.add_get("/", self._serve_index)
@ -107,6 +109,19 @@ class WebServer:
await self.bot.send_message(text, channel)
return web.json_response({"ok": True})
async def _api_node_config(self, request: web.Request) -> web.Response:
if not self.bot:
return web.json_response({"error": "Bot not available"}, status=503)
try:
cfg = self.bot.get_node_config()
return web.json_response(cfg)
except Exception:
logger.exception("Error getting node config")
return web.json_response({"error": "Failed to get config"}, status=500)
async def _serve_settings(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "settings.html"))
async def _serve_index(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "index.html"))

View file

@ -199,6 +199,41 @@
word-break: break-word;
}
/* ── Map wrapper ─────────────────────────────────── */
.map-wrapper {
padding: 0 !important;
overflow: hidden;
}
.map-wrapper #map {
width: 100%;
height: calc(100vh - 46px);
}
.node-tooltip { font-size: 13px; line-height: 1.6; }
.node-tooltip strong { color: #0f3460; }
.node-tooltip .label { color: #666; }
.node-tooltip-wrap { padding: 0; }
.legend {
background: rgba(255, 255, 255, 0.95);
padding: 10px 14px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
font-size: 13px;
line-height: 1.8;
}
.legend strong { display: block; margin-bottom: 4px; color: #333; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
flex-shrink: 0;
}
/* ── Scrollbar ───────────────────────────────────── */
.table-responsive::-webkit-scrollbar,

View file

@ -38,9 +38,12 @@
<a href="/scheduler" class="sidebar-link">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" target="_blank" class="sidebar-link">
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
</nav>
</aside>

View file

@ -1,10 +1,3 @@
const map = L.map('map').setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(map);
const markers = {};
const nodeData = {};
const statusDot = document.getElementById('statusDot');
@ -13,14 +6,14 @@ const nodeCount = document.getElementById('nodeCount');
let ws;
const hopColors = {
0: '#2196F3', // Direkt - Blau
1: '#4CAF50', // 1 Hop - Grün
2: '#FF9800', // 2 Hops - Orange
3: '#F44336', // 3 Hops - Rot
4: '#9C27B0', // 4 Hops - Lila
5: '#795548', // 5 Hops - Braun
0: '#2196F3',
1: '#4CAF50',
2: '#FF9800',
3: '#F44336',
4: '#9C27B0',
5: '#795548',
};
const hopColorDefault = '#9E9E9E'; // Unbekannt - Grau
const hopColorDefault = '#9E9E9E';
function getHopColor(hopCount) {
if (hopCount == null) return hopColorDefault;
@ -56,7 +49,7 @@ function nodeTooltip(node) {
<span class="label">Hops:</span> ${hops}<br>
<span class="label">SNR:</span> ${snr}<br>
<span class="label">Batterie:</span> ${bat}<br>
<span class="label">Höhe:</span> ${alt}<br>
<span class="label">Hoehe:</span> ${alt}<br>
<span class="label">Zuletzt:</span> ${lastSeen}
</div>`;
}
@ -147,6 +140,45 @@ legend.onAdd = function () {
});
return div;
};
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
localStorage.setItem('theme', theme);
}
applyTheme(localStorage.getItem('theme') || 'dark');
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// Sidebar toggle (mobile)
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('sidebar');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('open'));
sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open'));
}
// Init map after layout is ready
const map = L.map('map').setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(map);
legend.addTo(map);
// Invalidate size after short delay so sidebar layout settles
setTimeout(() => { map.invalidateSize(); }, 200);
connectWebSocket();

133
static/js/settings.js Normal file
View file

@ -0,0 +1,133 @@
// Labels for display
const deviceLabels = {
long_name: "Name",
short_name: "Kurzname",
node_num: "Node-Nummer",
hw_model: "Hardware",
firmware_version: "Firmware",
role: "Role",
max_channels: "Max Channels",
};
const loraLabels = {
region: "Region",
modem_preset: "Modem-Preset",
hop_limit: "Hop-Limit",
tx_power: "TX-Power",
bandwidth: "Bandwidth",
frequency_offset: "Freq-Offset",
tx_enabled: "TX aktiviert",
use_preset: "Preset nutzen",
};
const positionLabels = {
gps_mode: "GPS-Modus",
position_broadcast_secs: "Broadcast-Interval (s)",
fixed_position: "Feste Position",
};
const powerLabels = {
mesh_sds_timeout_secs: "Mesh-SDS-Timeout (s)",
ls_secs: "LS-Secs",
min_wake_secs: "Min-Wake-Secs",
is_power_saving: "Energiesparmodus",
};
function renderKeyValue(tableId, data, labels) {
const tbody = document.getElementById(tableId);
const rows = [];
for (const [key, label] of Object.entries(labels)) {
const val = data[key];
if (val === "" || val === undefined || val === null) continue;
const display = typeof val === "boolean" ? (val ? "Ja" : "Nein") : String(val);
rows.push(`<tr><td class="text-body-secondary" style="width:45%">${escapeHtml(label)}</td><td>${escapeHtml(display)}</td></tr>`);
}
tbody.innerHTML = rows.length > 0 ? rows.join("") : '<tr><td colspan="2" class="text-center text-body-secondary py-2">Keine Daten</td></tr>';
}
function renderChannels(channels) {
const tbody = document.getElementById("channelsTable");
if (!channels || channels.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-body-secondary py-2">Keine Channels</td></tr>';
return;
}
tbody.innerHTML = channels.map(ch =>
`<tr><td>${ch.index}</td><td>${escapeHtml(ch.name)}</td><td>${escapeHtml(ch.role)}</td><td>${escapeHtml(ch.psk)}</td></tr>`
).join("");
}
function renderBtNet(bluetooth, network) {
const tbody = document.getElementById("btNetTable");
const rows = [];
const btLabels = { enabled: "BT aktiviert", mode: "BT-Modus" };
const netLabels = { wifi_enabled: "WiFi aktiviert", ntp_server: "NTP-Server" };
for (const [key, label] of Object.entries(btLabels)) {
const val = bluetooth[key];
if (val === "" || val === undefined || val === null) continue;
const display = typeof val === "boolean" ? (val ? "Ja" : "Nein") : String(val);
rows.push(`<tr><td class="text-body-secondary" style="width:45%">${escapeHtml(label)}</td><td>${escapeHtml(display)}</td></tr>`);
}
for (const [key, label] of Object.entries(netLabels)) {
const val = network[key];
if (val === "" || val === undefined || val === null) continue;
const display = typeof val === "boolean" ? (val ? "Ja" : "Nein") : String(val);
rows.push(`<tr><td class="text-body-secondary" style="width:45%">${escapeHtml(label)}</td><td>${escapeHtml(display)}</td></tr>`);
}
tbody.innerHTML = rows.length > 0 ? rows.join("") : '<tr><td colspan="2" class="text-center text-body-secondary py-2">Keine Daten</td></tr>';
}
async function loadConfig() {
try {
const resp = await fetch("/api/node/config");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
renderKeyValue("deviceTable", data.device || {}, deviceLabels);
renderKeyValue("loraTable", data.lora || {}, loraLabels);
renderChannels(data.channels || []);
renderKeyValue("positionTable", data.position || {}, positionLabels);
renderKeyValue("powerTable", data.power || {}, powerLabels);
renderBtNet(data.bluetooth || {}, data.network || {});
} catch (e) {
console.error("Failed to load config:", e);
document.getElementById("deviceTable").innerHTML =
'<tr><td colspan="2" class="text-center text-danger py-2">Fehler beim Laden</td></tr>';
}
}
function escapeHtml(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Theme toggle
const themeToggle = document.getElementById("themeToggle");
const themeIcon = document.getElementById("themeIcon");
function applyTheme(theme) {
document.documentElement.setAttribute("data-bs-theme", theme);
themeIcon.className = theme === "dark" ? "bi bi-sun-fill" : "bi bi-moon-fill";
localStorage.setItem("theme", theme);
}
applyTheme(localStorage.getItem("theme") || "dark");
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-bs-theme");
applyTheme(current === "dark" ? "light" : "dark");
});
// Sidebar toggle (mobile)
const sidebarToggle = document.getElementById("sidebarToggle");
const sidebar = document.getElementById("sidebar");
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
if (sidebarToggle) {
sidebarToggle.addEventListener("click", () => sidebar.classList.toggle("open"));
sidebarBackdrop.addEventListener("click", () => sidebar.classList.remove("open"));
}
loadConfig();

View file

@ -1,84 +1,59 @@
<!DOCTYPE html>
<html lang="de">
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot Karte</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
#map { width: 100vw; height: 100vh; }
.node-tooltip { font-size: 13px; line-height: 1.6; }
.node-tooltip strong { color: #0f3460; }
.node-tooltip .label { color: #666; }
.node-tooltip-wrap { padding: 0; }
.node-tooltip-wrap .leaflet-tooltip-content { margin: 0; }
.legend {
background: rgba(255, 255, 255, 0.95);
padding: 10px 14px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
font-size: 13px;
line-height: 1.8;
}
.legend strong { display: block; margin-bottom: 4px; color: #333; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
flex-shrink: 0;
}
.status-bar {
position: fixed;
top: 10px;
right: 10px;
z-index: 1000;
background: rgba(26, 26, 46, 0.9);
color: #e0e0e0;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.status-bar .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff5252;
}
.status-bar .dot.connected {
background: #00e676;
box-shadow: 0 0 6px #00e676;
}
.back-link {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
background: rgba(26, 26, 46, 0.9);
color: #00d4ff;
padding: 8px 14px;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
}
.back-link:hover { background: rgba(26, 26, 46, 1); }
</style>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="map"></div>
<a href="/" class="back-link">Dashboard</a>
<div class="status-bar">
<span class="dot" id="statusDot"></span>
<span id="statusText">Verbinde...</span>
<span>|</span>
<span id="nodeCount">0 Nodes</span>
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<button class="btn btn-link text-body p-0 me-2 d-lg-none" id="sidebarToggle">
<i class="bi bi-list fs-5"></i>
</button>
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<div class="d-flex align-items-center gap-2">
<span class="d-flex align-items-center gap-1">
<span class="status-dot" id="statusDot"></span>
<small class="text-body-secondary" id="statusText">Verbinde...</small>
</span>
<small class="text-body-secondary" id="nodeCount">0 Nodes</small>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<nav class="sidebar-nav">
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link active">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
</nav>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<!-- Content -->
<main class="content-wrapper map-wrapper">
<div id="map"></div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/js/map.js"></script>

View file

@ -31,9 +31,12 @@
<a href="/scheduler" class="sidebar-link active">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" target="_blank" class="sidebar-link">
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
</nav>
</aside>

159
static/settings.html Normal file
View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot Einstellungen</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<button class="btn btn-link text-body p-0 me-2 d-lg-none" id="sidebarToggle">
<i class="bi bi-list fs-5"></i>
</button>
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</nav>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<nav class="sidebar-nav">
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link active">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
</nav>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<!-- Content -->
<main class="content-wrapper">
<h6 class="mb-2"><i class="bi bi-gear me-1 text-info"></i>Node-Einstellungen</h6>
<div class="row g-2">
<!-- Device -->
<div class="col-lg-6">
<div class="card card-outline card-info">
<div class="card-header">
<i class="bi bi-cpu me-1"></i>Geraet
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<tbody id="deviceTable">
<tr><td colspan="2" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- LoRa -->
<div class="col-lg-6">
<div class="card card-outline card-info">
<div class="card-header">
<i class="bi bi-broadcast me-1"></i>LoRa
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<tbody id="loraTable">
<tr><td colspan="2" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Channels -->
<div class="col-lg-6">
<div class="card card-outline card-warning">
<div class="card-header">
<i class="bi bi-list-ul me-1"></i>Channels
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="table-dark">
<tr>
<th>Index</th>
<th>Name</th>
<th>Role</th>
<th>PSK</th>
</tr>
</thead>
<tbody id="channelsTable">
<tr><td colspan="4" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Position -->
<div class="col-lg-6">
<div class="card card-outline card-success">
<div class="card-header">
<i class="bi bi-geo-alt me-1"></i>Position
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<tbody id="positionTable">
<tr><td colspan="2" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Power -->
<div class="col-lg-6">
<div class="card card-outline card-primary">
<div class="card-header">
<i class="bi bi-battery-charging me-1"></i>Power
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<tbody id="powerTable">
<tr><td colspan="2" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Bluetooth & Network -->
<div class="col-lg-6">
<div class="card card-outline">
<div class="card-header">
<i class="bi bi-bluetooth me-1"></i>Bluetooth &amp; Netzwerk
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<tbody id="btNetTable">
<tr><td colspan="2" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/settings.js"></script>
</body>
</html>