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
|
||||
|
||||
## [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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,7 +129,6 @@ 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}))
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -566,3 +519,4 @@ document.addEventListener('themechange', () => {
|
|||
});
|
||||
|
||||
initCharts();
|
||||
loadLinks();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
initPage({});
|
||||
connectWebSocket();
|
||||
}
|
||||
}});
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────
|
||||
function connectWebSocket() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue