diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ea6da..d18d276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.6.15] - 2026-02-18 + +### Added +- **Scheduler Template-Variablen**: Nachrichten-Jobs können Platzhalter nutzen: + `{time}`, `{date}`, `{datetime}`, `{weekday}`, `{nodes}`, `{nodes_24h}`. + Werden beim Ausführen serverseitig aufgelöst. +- **Scheduler UI**: Bei Typ „Nachricht" werden klickbare Variablen-Badges + unter dem Eingabefeld angezeigt – Klick fügt Variable an Cursorposition ein. +- **Footer**: Auf allen Seiten fixer Footer mit Copyright „MeshDD / PPfeiffer", + Versionsnummer und Monat/Jahr (MM/YYYY). + +### Fixed +- **Sauberer Shutdown**: WebSocket-Verbindungen werden vor `runner.cleanup()` + explizit geschlossen (`ws_manager.close_all()`), so dass der Prozess nicht + mehr auf hängende WS-Loops wartet. + ## [0.6.14] - 2026-02-18 ### Added diff --git a/config.yaml b/config.yaml index 563ca9e..1abda86 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -version: "0.6.14" +version: "0.6.15" bot: name: "MeshDD-Bot" diff --git a/main.py b/main.py index 4991a75..d9ddb46 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,7 @@ async def main(): finally: logger.info("Shutting down...") bot.disconnect() + await ws_manager.close_all() await runner.cleanup() await db.close() logger.info("Shutdown complete") diff --git a/meshbot/scheduler.py b/meshbot/scheduler.py index e92b471..4a7cfab 100644 --- a/meshbot/scheduler.py +++ b/meshbot/scheduler.py @@ -79,10 +79,33 @@ class Scheduler: job_type = job.get("type", "command") if command and self.bot: if job_type == "message": + command = await self._resolve_vars(command, datetime.now()) await self.bot.send_message(command, channel) else: await self.bot.execute_command(command, channel) + async def _resolve_vars(self, text: str, now: datetime) -> str: + if "{" not in text: + return text + stats: dict = {} + if self.bot and self.bot.db: + try: + stats = await self.bot.db.get_stats() + except Exception: + logger.exception("Error fetching stats for scheduler vars") + weekdays = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + replacements = { + "{time}": now.strftime("%H:%M"), + "{date}": now.strftime("%d.%m.%Y"), + "{datetime}": now.strftime("%d.%m.%Y %H:%M"), + "{weekday}": weekdays[now.weekday()], + "{nodes}": str(stats.get("total_nodes", "?")), + "{nodes_24h}": str(stats.get("nodes_24h", "?")), + } + for key, val in replacements.items(): + text = text.replace(key, val) + return text + @staticmethod def _matches_cron(cron_expr: str, dt: datetime) -> bool: parts = cron_expr.strip().split() diff --git a/meshbot/webserver.py b/meshbot/webserver.py index 079dc24..02080a8 100644 --- a/meshbot/webserver.py +++ b/meshbot/webserver.py @@ -19,6 +19,15 @@ class WebSocketManager: self.clients: set[web.WebSocketResponse] = set() self.auth_clients: set[web.WebSocketResponse] = set() + async def close_all(self): + for ws in list(self.clients): + try: + await ws.close() + except Exception: + pass + self.clients.clear() + self.auth_clients.clear() + async def broadcast(self, msg_type: str, data: dict | list): message = json.dumps({"type": msg_type, "data": data}) closed = set() diff --git a/static/admin.html b/static/admin.html index b176cb5..eff3c42 100644 --- a/static/admin.html +++ b/static/admin.html @@ -186,6 +186,8 @@ + + diff --git a/static/css/style.css b/static/css/style.css index 81de6f9..8d7fb5c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -104,6 +104,7 @@ margin-top: 48px; margin-left: 210px; padding: .875rem; + padding-bottom: calc(.875rem + 26px); min-height: calc(100vh - 48px); background: var(--tblr-body-bg, var(--bs-body-bg)); } @@ -253,7 +254,7 @@ .map-wrapper #map { width: 100%; - height: calc(100vh - 48px); + height: calc(100vh - 48px - 26px); } .node-tooltip { font-size: 13px; line-height: 1.6; } @@ -291,6 +292,29 @@ font-size: .65rem !important; } +/* ── Page Footer ─────────────────────────────────────────────── */ + +.page-footer { + position: fixed; + bottom: 0; + left: 210px; + right: 0; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + font-size: .65rem; + color: var(--bs-secondary-color); + background: var(--tblr-bg-surface, var(--bs-body-bg)); + border-top: 1px solid var(--tblr-border-color, var(--bs-border-color)); + z-index: 900; + letter-spacing: .02em; +} + +@media (max-width: 991.98px) { + .page-footer { left: 0; } +} + /* ── Scrollbars ──────────────────────────────────────────────── */ .table-responsive::-webkit-scrollbar, diff --git a/static/index.html b/static/index.html index 885f3d8..8fb1d14 100644 --- a/static/index.html +++ b/static/index.html @@ -101,9 +101,9 @@
- +
-
+
Kanal-Anfragen
@@ -111,7 +111,7 @@
-
+
Hop-Verteilung
@@ -119,7 +119,7 @@
-
+
Hardware Top 5
@@ -127,6 +127,14 @@
+
+
+
Pakettypen (24h)
+
+ +
+
+
@@ -219,6 +227,8 @@
+ + diff --git a/static/js/app.js b/static/js/app.js index 004ae7a..747e79d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -91,6 +91,16 @@ function escapeHtml(str) { // ── Public init ─────────────────────────────────────────────── +function _injectFooter(version) { + const footer = document.getElementById('pageFooter'); + if (!footer) return; + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const yyyy = now.getFullYear(); + const ver = version ? ` · v${version}` : ''; + footer.textContent = `© MeshDD / PPfeiffer${ver} · ${mm}/${yyyy}`; +} + function initPage({ onAuth = null } = {}) { _injectSidebar(); _setupTheme(); @@ -103,9 +113,10 @@ function initPage({ onAuth = null } = {}) { if (onAuth) onAuth(user); }); const vl = document.getElementById('versionLabel'); - if (vl) { - fetch('/api/stats') - .then(r => r.ok ? r.json() : null) - .then(d => { if (d?.version) vl.textContent = `v${d.version}`; }); - } + fetch('/api/stats') + .then(r => r.ok ? r.json() : null) + .then(d => { + if (d?.version && vl) vl.textContent = `v${d.version}`; + _injectFooter(d?.version); + }); } diff --git a/static/js/login.js b/static/js/login.js index a9c7c85..85796e2 100644 --- a/static/js/login.js +++ b/static/js/login.js @@ -15,6 +15,21 @@ themeToggle.addEventListener('click', () => { applyTheme(current === 'dark' ? 'light' : 'dark'); }); +// Footer +(function () { + const footer = document.getElementById('pageFooter'); + if (!footer) return; + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const yyyy = now.getFullYear(); + fetch('/api/stats').then(r => r.ok ? r.json() : null).then(d => { + const ver = d?.version ? ` · v${d.version}` : ''; + footer.textContent = `© MeshDD / PPfeiffer${ver} · ${mm}/${yyyy}`; + }).catch(() => { + footer.textContent = `© MeshDD / PPfeiffer · ${mm}/${yyyy}`; + }); +})(); + // View switching const views = { login: document.getElementById('loginView'), diff --git a/static/js/scheduler.js b/static/js/scheduler.js index b8bb4aa..97af527 100644 --- a/static/js/scheduler.js +++ b/static/js/scheduler.js @@ -6,13 +6,46 @@ let editMode = false; initPage({ onAuth: (user) => { currentUser = user; } }); -// Type toggle label +// Template variables available in message jobs +const MSG_VARS = [ + { key: '{time}', label: '{time}', desc: 'Uhrzeit (HH:MM)' }, + { key: '{date}', label: '{date}', desc: 'Datum (TT.MM.JJJJ)' }, + { key: '{datetime}', label: '{datetime}', desc: 'Datum + Uhrzeit' }, + { key: '{weekday}', label: '{weekday}', desc: 'Wochentag (Montag…)' }, + { key: '{nodes}', label: '{nodes}', desc: 'Anzahl Nodes gesamt' }, + { key: '{nodes_24h}', label: '{nodes_24h}', desc: 'Aktive Nodes (24h)' }, +]; + +// Type toggle label + variable hints function updateCommandLabel() { const isMsg = document.getElementById('jobType').value === 'message'; document.getElementById('jobCommandLabel').textContent = isMsg ? 'Nachricht' : 'Kommando'; - document.getElementById('jobCommand').placeholder = isMsg ? 'Hallo Mesh!' : '/weather'; + document.getElementById('jobCommand').placeholder = isMsg ? 'Hallo Mesh! Heute ist {weekday}, {time} Uhr.' : '/weather'; + const hints = document.getElementById('varHints'); + const badges = document.getElementById('varBadges'); + if (isMsg) { + badges.innerHTML = MSG_VARS.map(v => + `${escapeHtml(v.label)}` + ).join(''); + hints.classList.remove('d-none'); + } else { + hints.classList.add('d-none'); + } } +document.getElementById('varBadges').addEventListener('click', (e) => { + const badge = e.target.closest('[data-var]'); + if (!badge) return; + const input = document.getElementById('jobCommand'); + const start = input.selectionStart; + const end = input.selectionEnd; + const val = input.value; + input.value = val.slice(0, start) + badge.dataset.var + val.slice(end); + const pos = start + badge.dataset.var.length; + input.setSelectionRange(pos, pos); + input.focus(); +}); + document.getElementById('jobType').addEventListener('change', updateCommandLabel); // WebSocket for live updates diff --git a/static/login.html b/static/login.html index e78c67a..114932e 100644 --- a/static/login.html +++ b/static/login.html @@ -117,6 +117,8 @@
+ + diff --git a/static/map.html b/static/map.html index c8c9a27..a20019b 100644 --- a/static/map.html +++ b/static/map.html @@ -48,6 +48,8 @@
+ + diff --git a/static/packets.html b/static/packets.html index 5d5c151..3c0496f 100644 --- a/static/packets.html +++ b/static/packets.html @@ -78,6 +78,8 @@ + + diff --git a/static/scheduler.html b/static/scheduler.html index 00ea962..8591732 100644 --- a/static/scheduler.html +++ b/static/scheduler.html @@ -98,6 +98,10 @@
+
+ Variablen: + +
@@ -124,6 +128,8 @@
+ + diff --git a/static/settings.html b/static/settings.html index c88fb59..01679cd 100644 --- a/static/settings.html +++ b/static/settings.html @@ -148,6 +148,8 @@ + +