feat(messages/dashboard): Nachrichten öffentlich, Links-Card, Trenner, Badge-Fix

- Nachrichten-Seite /messages ohne Login zugänglich (closes #11)
- new_message/initial_messages an alle WS-Clients (broadcast statt broadcast_auth)
- Dashboard: Nachrichten-Card entfernt, Links-Card (config.yaml) eingefügt
- GET /api/links gibt konfigurierte Links aus config.yaml zurück
- Nachrichten-Trenner: var(--bs-border-color) statt translucent
- msgCount-Badge: bg-secondary-subtle/text-secondary-emphasis (theme-aware)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-20 14:41:17 +01:00
parent 33c05c0a32
commit a5ab4550f2
9 changed files with 71 additions and 110 deletions

View file

@ -1,5 +1,21 @@
# Changelog # Changelog
## [0.08.14] - 2026-02-20
### Added
- **Dashboard Links-Card**: Neue Card mit frei konfigurierbaren Links aus `config.yaml`
(`links:` Liste mit `url` + `label`). Ersetzt die Nachrichten-Card im Dashboard (closes #11).
- **`GET /api/links`**: Neuer Endpunkt gibt konfigurierte Links zurück.
### Changed
- **Nachrichten-Seite öffentlich** (closes #11): Kein Login mehr erforderlich für `/messages`.
`initial_messages` und `new_message` werden an alle WebSocket-Clients gesendet.
- **Dashboard**: Nachrichten-Card entfernt; Nodes-Card auf `col-lg-8` verbreitert.
- **Nachrichtenliste Trenner**: `border-bottom` von `translucent` auf `var(--bs-border-color)`
deutlich sichtbarer Trenner zwischen Nachrichten.
- **`msgCount`-Badge theme-aware**: `bg-secondary``bg-secondary-subtle text-secondary-emphasis`
Text in Hell- und Dunkel-Theme lesbar.
## [0.08.13] - 2026-02-20 ## [0.08.13] - 2026-02-20
### Added ### Added

View file

@ -1,4 +1,4 @@
version: "0.08.13" version: "0.08.14"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"
@ -17,3 +17,9 @@ database:
auth: auth:
session_max_age: 86400 session_max_age: 86400
links:
- url: "https://meshtastic.org"
label: "Meshtastic"
- url: "https://meshmap.net"
label: "MeshMap"

View file

@ -357,7 +357,7 @@ class MeshBot:
if not is_own: if not is_own:
msg = await self.db.insert_message(str(from_id), str(to_id), channel, portnum, text) msg = await self.db.insert_message(str(from_id), str(to_id), channel, portnum, text)
if self.ws_manager: if self.ws_manager:
await self.ws_manager.broadcast_auth("new_message", msg) await self.ws_manager.broadcast("new_message",msg)
await self._broadcast_stats() await self._broadcast_stats()
# Process commands # Process commands
@ -464,7 +464,7 @@ class MeshBot:
try: try:
stored = await self.db.insert_message(my_id, "^all", channel, "TEXT_MESSAGE_APP", msg) stored = await self.db.insert_message(my_id, "^all", channel, "TEXT_MESSAGE_APP", msg)
if self.ws_manager: if self.ws_manager:
await self.ws_manager.broadcast_auth("new_message", stored) await self.ws_manager.broadcast("new_message",stored)
except Exception: except Exception:
logger.exception("Error storing sent message") logger.exception("Error storing sent message")
try: try:

View file

@ -80,6 +80,7 @@ class WebServer:
self.app.router.add_get("/api/nina/config", self._api_nina_get) self.app.router.add_get("/api/nina/config", self._api_nina_get)
self.app.router.add_put("/api/nina/config", self._api_nina_update) self.app.router.add_put("/api/nina/config", self._api_nina_update)
self.app.router.add_get("/api/nina/alerts", self._api_nina_alerts) self.app.router.add_get("/api/nina/alerts", self._api_nina_alerts)
self.app.router.add_get("/api/links", self._api_links)
self.app.router.add_get("/login", self._serve_login) self.app.router.add_get("/login", self._serve_login)
self.app.router.add_get("/register", self._serve_login) self.app.router.add_get("/register", self._serve_login)
self.app.router.add_get("/admin", self._serve_admin) self.app.router.add_get("/admin", self._serve_admin)
@ -128,7 +129,6 @@ class WebServer:
packets = await self.db.get_recent_packets(200) packets = await self.db.get_recent_packets(200)
await ws.send_str(json.dumps({"type": "initial_packets", "data": packets})) await ws.send_str(json.dumps({"type": "initial_packets", "data": packets}))
if user:
messages = await self.db.get_recent_messages(50) messages = await self.db.get_recent_messages(50)
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages})) await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
@ -164,6 +164,10 @@ class WebServer:
stats["bot_connected"] = self.bot.is_connected() stats["bot_connected"] = self.bot.is_connected()
return web.json_response(stats) return web.json_response(stats)
async def _api_links(self, request: web.Request) -> web.Response:
links = config.get("links", []) or []
return web.json_response(links)
async def _api_send(self, request: web.Request) -> web.Response: async def _api_send(self, request: web.Request) -> web.Response:
require_user_api(request) require_user_api(request)
if not self.bot: if not self.bot:

View file

@ -305,8 +305,8 @@
/* ── Full Messages Page ──────────────────────────────────────── */ /* ── Full Messages Page ──────────────────────────────────────── */
.msg-full-item { .msg-full-item {
padding: .5rem .875rem; padding: .6rem .875rem;
border-bottom: 1px solid var(--bs-border-color-translucent); border-bottom: 1px solid var(--bs-border-color);
} }
.msg-full-item:last-child { border-bottom: none; } .msg-full-item:last-child { border-bottom: none; }

View file

@ -152,7 +152,7 @@
<!-- Main Cards --> <!-- Main Cards -->
<div class="row g-2"> <div class="row g-2">
<!-- Nodes --> <!-- Nodes -->
<div class="col-lg-7"> <div class="col-lg-8">
<div class="card card-outline card-info"> <div class="card card-outline card-info">
<div class="card-header d-flex align-items-center gap-2 flex-wrap py-1"> <div class="card-header d-flex align-items-center gap-2 flex-wrap py-1">
<i class="bi bi-router me-1"></i>Nodes <i class="bi bi-router me-1"></i>Nodes
@ -184,15 +184,16 @@
</div> </div>
</div> </div>
<!-- Messages (auth-gated) --> <!-- Links Card -->
<div class="col-lg-5 d-none" id="messagesCard"> <div class="col-lg-4">
<div class="card card-outline card-warning"> <div class="card card-outline card-secondary">
<div class="card-header py-1 d-flex align-items-center gap-1 flex-wrap"> <div class="card-header py-1">
<i class="bi bi-chat-dots me-1"></i>Nachrichten <i class="bi bi-link-45deg me-1"></i>Links
<div class="ms-auto d-flex gap-1 flex-wrap" id="msgFilterBar"></div>
</div> </div>
<div class="card-body p-0" style="max-height:520px;overflow-y:auto"> <div class="card-body p-0">
<div id="messagesList"></div> <ul class="list-group list-group-flush" id="linksList">
<li class="list-group-item text-body-secondary py-2 px-3" style="font-size:.85rem">Lade...</li>
</ul>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,4 @@
const nodesTable = document.getElementById('nodesTable'); const nodesTable = document.getElementById('nodesTable');
const messagesList = document.getElementById('messagesList');
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText'); const statusText = document.getElementById('statusText');
const nodeCountBadge = document.getElementById('nodeCountBadge'); const nodeCountBadge = document.getElementById('nodeCountBadge');
@ -13,7 +12,6 @@ let nodeSearch = '';
let nodeOnlineFilter = false; let nodeOnlineFilter = false;
let nodeSortKey = 'last_seen'; let nodeSortKey = 'last_seen';
let nodeSortDir = -1; let nodeSortDir = -1;
let msgChannelFilter = 'all';
let chartChannel = null; let chartChannel = null;
let chartHops = null; let chartHops = null;
let chartHardware = null; let chartHardware = null;
@ -26,12 +24,8 @@ initPage({ onAuth: (user) => {
} }); } });
function updateVisibility() { function updateVisibility() {
const loggedIn = !!currentUser; if (currentUser) {
const sendCard = document.getElementById('sendCard'); document.getElementById('sendCard').classList.remove('d-none');
const messagesCard = document.getElementById('messagesCard');
if (loggedIn) {
sendCard.classList.remove('d-none');
messagesCard.classList.remove('d-none');
} }
} }
@ -65,22 +59,14 @@ function connectWebSocket() {
nodes[msg.data.node_id] = msg.data; nodes[msg.data.node_id] = msg.data;
renderNodes(); renderNodes();
break; break;
case 'initial_messages':
msg.data.reverse().forEach(m => addMessage(m));
applyMsgFilter();
break;
case 'channels': case 'channels':
channels = msg.data; channels = msg.data;
populateChannelDropdown(); populateChannelDropdown();
renderMsgFilterBar();
if (lastStats) updateChannelChart(lastStats); if (lastStats) updateChannelChart(lastStats);
break; break;
case 'my_node_id': case 'my_node_id':
myNodeId = msg.data; myNodeId = msg.data;
break; break;
case 'new_message':
addMessage(msg.data);
break;
case 'stats_update': case 'stats_update':
updateStats(msg.data); updateStats(msg.data);
break; break;
@ -161,32 +147,6 @@ function renderBattery(level) {
return `<span class="${colorClass}"><i class="bi ${iconClass} me-1"></i>${level}%</span>`; return `<span class="${colorClass}"><i class="bi ${iconClass} me-1"></i>${level}%</span>`;
} }
function addMessage(msg) {
const item = document.createElement('div');
const isSent = myNodeId && msg.from_node === myNodeId;
const chIdx = msg.channel != null ? msg.channel : '?';
item.className = 'msg-item' + (isSent ? ' msg-sent' : '');
item.dataset.channel = String(chIdx);
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : '';
const fromNode = nodes[msg.from_node];
const from = isSent ? 'Bot' : ((fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?'));
const chName = channels[chIdx] || `Ch ${chIdx}`;
const bubbleClass = isSent ? 'msg-bubble msg-bubble-sent' : 'msg-bubble';
const icon = isSent ? 'bi-send-fill' : 'bi-person-fill';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-medium"><i class="bi ${icon} me-1 text-body-secondary"></i>${escapeHtml(from)}</small>
<small class="text-body-secondary"><span class="badge rounded-pill bg-secondary text-white me-1">${escapeHtml(chName)}</span>${time}</small>
</div>
<div class="${bubbleClass}">${escapeHtml(msg.payload || '')}</div>`;
if (msgChannelFilter !== 'all' && String(chIdx) !== msgChannelFilter) {
item.classList.add('d-none');
}
messagesList.prepend(item);
while (messagesList.children.length > 100) {
messagesList.removeChild(messagesList.lastChild);
}
}
function updateBotStatus(status) { function updateBotStatus(status) {
const dot = document.getElementById('meshDot'); const dot = document.getElementById('meshDot');
@ -238,33 +198,26 @@ function timeAgo(timestamp) {
return `${Math.floor(seconds / 86400)}d`; return `${Math.floor(seconds / 86400)}d`;
} }
// ── Message channel filter (Prio 7) ────────────────── // ── Links Card ────────────────────────────────────────
function renderMsgFilterBar() { async function loadLinks() {
const bar = document.getElementById('msgFilterBar'); try {
if (!bar) return; const resp = await fetch('/api/links');
const sorted = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); const links = await resp.json();
const mkBtn = (ch, label) => { const list = document.getElementById('linksList');
const active = ch === msgChannelFilter; if (!links.length) {
return `<button class="btn btn-sm ${active ? 'btn-secondary' : 'btn-outline-secondary'} py-0 px-1" data-ch="${escapeHtml(ch)}" style="font-size:.7rem">${escapeHtml(label)}</button>`; list.innerHTML = '<li class="list-group-item text-body-secondary py-2 px-3" style="font-size:.85rem">Keine Links konfiguriert</li>';
}; return;
bar.innerHTML = mkBtn('all', 'Alle') + sorted.map(([idx, name]) => mkBtn(String(idx), name)).join(''); }
bar.onclick = (e) => { list.innerHTML = links.map(l =>
const btn = e.target.closest('button[data-ch]'); `<li class="list-group-item py-2 px-3">
if (!btn) return; <a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="text-decoration-none" style="font-size:.85rem">
msgChannelFilter = btn.dataset.ch; <i class="bi bi-box-arrow-up-right me-1 text-body-secondary" style="font-size:.7rem"></i>${escapeHtml(l.label)}
bar.querySelectorAll('button[data-ch]').forEach(b => { </a>
b.classList.toggle('btn-secondary', b.dataset.ch === msgChannelFilter); </li>`
b.classList.toggle('btn-outline-secondary', b.dataset.ch !== msgChannelFilter); ).join('');
}); } catch (e) {
applyMsgFilter(); console.error('Links load failed:', e);
}; }
}
function applyMsgFilter() {
messagesList.querySelectorAll('.msg-item[data-channel]').forEach(item => {
const visible = msgChannelFilter === 'all' || item.dataset.channel === msgChannelFilter;
item.classList.toggle('d-none', !visible);
});
} }
// Send message // Send message
@ -566,3 +519,4 @@ document.addEventListener('themechange', () => {
}); });
initCharts(); initCharts();
loadLinks();

View file

@ -22,14 +22,8 @@ let ws;
let msgChannelFilter = 'all'; let msgChannelFilter = 'all';
let messages = []; let messages = [];
initPage({ onAuth: (user) => { initPage({});
if (!user) { connectWebSocket();
document.getElementById('loginNotice').classList.remove('d-none');
} else {
document.getElementById('messagesView').classList.remove('d-none');
connectWebSocket();
}
}});
// ── WebSocket ───────────────────────────────────────────────── // ── WebSocket ─────────────────────────────────────────────────
function connectWebSocket() { function connectWebSocket() {

View file

@ -42,26 +42,12 @@
<div class="sidebar-backdrop" id="sidebarBackdrop"></div> <div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<main class="content-wrapper"> <main class="content-wrapper">
<!-- Login required notice --> <div id="messagesView">
<div id="loginNotice" class="d-none">
<div class="card card-outline text-center py-5">
<div class="card-body">
<i class="bi bi-lock fs-1 text-body-secondary d-block mb-3"></i>
<p class="mb-3 text-body-secondary">Du musst angemeldet sein, um Nachrichten zu sehen.</p>
<a href="/login" class="btn btn-info btn-sm">
<i class="bi bi-person me-1"></i>Anmelden
</a>
</div>
</div>
</div>
<!-- Messages view (auth-gated) -->
<div id="messagesView" class="d-none">
<div class="card card-outline card-warning"> <div class="card card-outline card-warning">
<div class="card-header py-2 d-flex align-items-center gap-2 flex-wrap"> <div class="card-header py-2 d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-chat-dots me-1"></i> <i class="bi bi-chat-dots me-1"></i>
<span class="fw-semibold">Nachrichten</span> <span class="fw-semibold">Nachrichten</span>
<span class="badge bg-secondary ms-1" id="msgCount">0</span> <span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle ms-1" id="msgCount">0</span>
<!-- Channel filter tabs --> <!-- Channel filter tabs -->
<div class="ms-auto d-flex gap-1 flex-wrap" id="msgFilterBar"></div> <div class="ms-auto d-flex gap-1 flex-wrap" id="msgFilterBar"></div>
<button class="btn btn-sm btn-outline-danger py-0 px-2" id="msgClearBtn" title="Nachrichten löschen"> <button class="btn btn-sm btn-outline-danger py-0 px-2" id="msgClearBtn" title="Nachrichten löschen">