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
## [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
### Added

View file

@ -1,4 +1,4 @@
version: "0.08.13"
version: "0.08.14"
bot:
name: "MeshDD-Bot"
@ -17,3 +17,9 @@ database:
auth:
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:
msg = await self.db.insert_message(str(from_id), str(to_id), channel, portnum, text)
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()
# Process commands
@ -464,7 +464,7 @@ class MeshBot:
try:
stored = await self.db.insert_message(my_id, "^all", channel, "TEXT_MESSAGE_APP", msg)
if self.ws_manager:
await self.ws_manager.broadcast_auth("new_message", stored)
await self.ws_manager.broadcast("new_message",stored)
except Exception:
logger.exception("Error storing sent message")
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_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/links", self._api_links)
self.app.router.add_get("/login", self._serve_login)
self.app.router.add_get("/register", self._serve_login)
self.app.router.add_get("/admin", self._serve_admin)
@ -128,9 +129,8 @@ class WebServer:
packets = await self.db.get_recent_packets(200)
await ws.send_str(json.dumps({"type": "initial_packets", "data": packets}))
if user:
messages = await self.db.get_recent_messages(50)
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
messages = await self.db.get_recent_messages(50)
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
async for msg in ws:
pass # We only send, not receive
@ -164,6 +164,10 @@ class WebServer:
stats["bot_connected"] = self.bot.is_connected()
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:
require_user_api(request)
if not self.bot:

View file

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

View file

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

View file

@ -1,5 +1,4 @@
const nodesTable = document.getElementById('nodesTable');
const messagesList = document.getElementById('messagesList');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const nodeCountBadge = document.getElementById('nodeCountBadge');
@ -13,7 +12,6 @@ let nodeSearch = '';
let nodeOnlineFilter = false;
let nodeSortKey = 'last_seen';
let nodeSortDir = -1;
let msgChannelFilter = 'all';
let chartChannel = null;
let chartHops = null;
let chartHardware = null;
@ -26,12 +24,8 @@ initPage({ onAuth: (user) => {
} });
function updateVisibility() {
const loggedIn = !!currentUser;
const sendCard = document.getElementById('sendCard');
const messagesCard = document.getElementById('messagesCard');
if (loggedIn) {
sendCard.classList.remove('d-none');
messagesCard.classList.remove('d-none');
if (currentUser) {
document.getElementById('sendCard').classList.remove('d-none');
}
}
@ -65,22 +59,14 @@ function connectWebSocket() {
nodes[msg.data.node_id] = msg.data;
renderNodes();
break;
case 'initial_messages':
msg.data.reverse().forEach(m => addMessage(m));
applyMsgFilter();
break;
case 'channels':
channels = msg.data;
populateChannelDropdown();
renderMsgFilterBar();
if (lastStats) updateChannelChart(lastStats);
break;
case 'my_node_id':
myNodeId = msg.data;
break;
case 'new_message':
addMessage(msg.data);
break;
case 'stats_update':
updateStats(msg.data);
break;
@ -161,32 +147,6 @@ function renderBattery(level) {
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) {
const dot = document.getElementById('meshDot');
@ -238,33 +198,26 @@ function timeAgo(timestamp) {
return `${Math.floor(seconds / 86400)}d`;
}
// ── Message channel filter (Prio 7) ──────────────────
function renderMsgFilterBar() {
const bar = document.getElementById('msgFilterBar');
if (!bar) return;
const sorted = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
const mkBtn = (ch, label) => {
const active = ch === msgChannelFilter;
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>`;
};
bar.innerHTML = mkBtn('all', 'Alle') + sorted.map(([idx, name]) => mkBtn(String(idx), name)).join('');
bar.onclick = (e) => {
const btn = e.target.closest('button[data-ch]');
if (!btn) return;
msgChannelFilter = btn.dataset.ch;
bar.querySelectorAll('button[data-ch]').forEach(b => {
b.classList.toggle('btn-secondary', b.dataset.ch === msgChannelFilter);
b.classList.toggle('btn-outline-secondary', b.dataset.ch !== msgChannelFilter);
});
applyMsgFilter();
};
}
function applyMsgFilter() {
messagesList.querySelectorAll('.msg-item[data-channel]').forEach(item => {
const visible = msgChannelFilter === 'all' || item.dataset.channel === msgChannelFilter;
item.classList.toggle('d-none', !visible);
});
// ── Links Card ────────────────────────────────────────
async function loadLinks() {
try {
const resp = await fetch('/api/links');
const links = await resp.json();
const list = document.getElementById('linksList');
if (!links.length) {
list.innerHTML = '<li class="list-group-item text-body-secondary py-2 px-3" style="font-size:.85rem">Keine Links konfiguriert</li>';
return;
}
list.innerHTML = links.map(l =>
`<li class="list-group-item py-2 px-3">
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="text-decoration-none" style="font-size:.85rem">
<i class="bi bi-box-arrow-up-right me-1 text-body-secondary" style="font-size:.7rem"></i>${escapeHtml(l.label)}
</a>
</li>`
).join('');
} catch (e) {
console.error('Links load failed:', e);
}
}
// Send message
@ -566,3 +519,4 @@ document.addEventListener('themechange', () => {
});
initCharts();
loadLinks();

View file

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

View file

@ -42,26 +42,12 @@
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<main class="content-wrapper">
<!-- Login required notice -->
<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 id="messagesView">
<div class="card card-outline card-warning">
<div class="card-header py-2 d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-chat-dots me-1"></i>
<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 -->
<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">