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:
parent
33c05c0a32
commit
a5ab4550f2
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue