feat: v0.6.15 - Scheduler-Variablen, Footer, sauberer WS-Shutdown

- Scheduler Nachrichten: Template-Variablen {time}, {date}, {datetime},
  {weekday}, {nodes}, {nodes_24h} serverseitig aufgelöst
- Scheduler UI: klickbare Variablen-Badges beim Nachrichtenfeld-Modus
- Footer auf allen Seiten: © MeshDD / PPfeiffer · vX.Y.Z · MM/YYYY
- Fix: WS-Verbindungen vor runner.cleanup() explizit geschlossen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-18 18:13:15 +01:00
parent 0fd401a395
commit 997a29842f
16 changed files with 171 additions and 13 deletions

View file

@ -1,5 +1,21 @@
# Changelog # 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 ## [0.6.14] - 2026-02-18
### Added ### Added

View file

@ -1,4 +1,4 @@
version: "0.6.14" version: "0.6.15"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -64,6 +64,7 @@ async def main():
finally: finally:
logger.info("Shutting down...") logger.info("Shutting down...")
bot.disconnect() bot.disconnect()
await ws_manager.close_all()
await runner.cleanup() await runner.cleanup()
await db.close() await db.close()
logger.info("Shutdown complete") logger.info("Shutdown complete")

View file

@ -79,10 +79,33 @@ class Scheduler:
job_type = job.get("type", "command") job_type = job.get("type", "command")
if command and self.bot: if command and self.bot:
if job_type == "message": if job_type == "message":
command = await self._resolve_vars(command, datetime.now())
await self.bot.send_message(command, channel) await self.bot.send_message(command, channel)
else: else:
await self.bot.execute_command(command, channel) 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 @staticmethod
def _matches_cron(cron_expr: str, dt: datetime) -> bool: def _matches_cron(cron_expr: str, dt: datetime) -> bool:
parts = cron_expr.strip().split() parts = cron_expr.strip().split()

View file

@ -19,6 +19,15 @@ class WebSocketManager:
self.clients: set[web.WebSocketResponse] = set() self.clients: set[web.WebSocketResponse] = set()
self.auth_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): async def broadcast(self, msg_type: str, data: dict | list):
message = json.dumps({"type": msg_type, "data": data}) message = json.dumps({"type": msg_type, "data": data})
closed = set() closed = set()

View file

@ -186,6 +186,8 @@
</div> </div>
</div> </div>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/admin.js"></script> <script src="/static/js/admin.js"></script>

View file

@ -104,6 +104,7 @@
margin-top: 48px; margin-top: 48px;
margin-left: 210px; margin-left: 210px;
padding: .875rem; padding: .875rem;
padding-bottom: calc(.875rem + 26px);
min-height: calc(100vh - 48px); min-height: calc(100vh - 48px);
background: var(--tblr-body-bg, var(--bs-body-bg)); background: var(--tblr-body-bg, var(--bs-body-bg));
} }
@ -253,7 +254,7 @@
.map-wrapper #map { .map-wrapper #map {
width: 100%; width: 100%;
height: calc(100vh - 48px); height: calc(100vh - 48px - 26px);
} }
.node-tooltip { font-size: 13px; line-height: 1.6; } .node-tooltip { font-size: 13px; line-height: 1.6; }
@ -291,6 +292,29 @@
font-size: .65rem !important; 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 ──────────────────────────────────────────────── */ /* ── Scrollbars ──────────────────────────────────────────────── */
.table-responsive::-webkit-scrollbar, .table-responsive::-webkit-scrollbar,

View file

@ -101,9 +101,9 @@
<div class="card-body py-2 px-3 d-flex gap-1 flex-wrap" id="channelBreakdown"></div> <div class="card-body py-2 px-3 d-flex gap-1 flex-wrap" id="channelBreakdown"></div>
</div> </div>
<!-- Charts (Prio 5) --> <!-- Charts -->
<div class="row g-2 mb-2"> <div class="row g-2 mb-2">
<div class="col-md-4"> <div class="col-md-6 col-xl-3">
<div class="card card-outline"> <div class="card card-outline">
<div class="card-header py-1"><i class="bi bi-pie-chart me-1"></i>Kanal-Anfragen</div> <div class="card-header py-1"><i class="bi bi-pie-chart me-1"></i>Kanal-Anfragen</div>
<div class="card-body p-2" style="height:160px;position:relative"> <div class="card-body p-2" style="height:160px;position:relative">
@ -111,7 +111,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-6 col-xl-3">
<div class="card card-outline"> <div class="card card-outline">
<div class="card-header py-1"><i class="bi bi-bar-chart me-1"></i>Hop-Verteilung</div> <div class="card-header py-1"><i class="bi bi-bar-chart me-1"></i>Hop-Verteilung</div>
<div class="card-body p-2" style="height:160px;position:relative"> <div class="card-body p-2" style="height:160px;position:relative">
@ -119,7 +119,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-6 col-xl-3">
<div class="card card-outline"> <div class="card card-outline">
<div class="card-header py-1"><i class="bi bi-cpu me-1"></i>Hardware Top 5</div> <div class="card-header py-1"><i class="bi bi-cpu me-1"></i>Hardware Top 5</div>
<div class="card-body p-2" style="height:160px;position:relative"> <div class="card-body p-2" style="height:160px;position:relative">
@ -127,6 +127,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3">
<div class="card card-outline">
<div class="card-header py-1"><i class="bi bi-reception-4 me-1"></i>Pakettypen (24h)</div>
<div class="card-body p-2" style="height:160px;position:relative">
<canvas id="chartPacketTypes"></canvas>
</div>
</div>
</div>
</div> </div>
<!-- Send Message (auth-gated) --> <!-- Send Message (auth-gated) -->
@ -219,6 +227,8 @@
</div> </div>
</div> </div>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>

View file

@ -91,6 +91,16 @@ function escapeHtml(str) {
// ── Public init ─────────────────────────────────────────────── // ── 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 } = {}) { function initPage({ onAuth = null } = {}) {
_injectSidebar(); _injectSidebar();
_setupTheme(); _setupTheme();
@ -103,9 +113,10 @@ function initPage({ onAuth = null } = {}) {
if (onAuth) onAuth(user); if (onAuth) onAuth(user);
}); });
const vl = document.getElementById('versionLabel'); const vl = document.getElementById('versionLabel');
if (vl) {
fetch('/api/stats') fetch('/api/stats')
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(d => { if (d?.version) vl.textContent = `v${d.version}`; }); .then(d => {
} if (d?.version && vl) vl.textContent = `v${d.version}`;
_injectFooter(d?.version);
});
} }

View file

@ -15,6 +15,21 @@ themeToggle.addEventListener('click', () => {
applyTheme(current === 'dark' ? 'light' : 'dark'); 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 // View switching
const views = { const views = {
login: document.getElementById('loginView'), login: document.getElementById('loginView'),

View file

@ -6,13 +6,46 @@ let editMode = false;
initPage({ onAuth: (user) => { currentUser = user; } }); 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() { function updateCommandLabel() {
const isMsg = document.getElementById('jobType').value === 'message'; const isMsg = document.getElementById('jobType').value === 'message';
document.getElementById('jobCommandLabel').textContent = isMsg ? 'Nachricht' : 'Kommando'; 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 =>
`<span class="badge bg-secondary me-1 mb-1" role="button" title="${escapeHtml(v.desc)}" data-var="${escapeHtml(v.key)}" style="cursor:pointer;font-size:.7rem">${escapeHtml(v.label)}</span>`
).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); document.getElementById('jobType').addEventListener('change', updateCommandLabel);
// WebSocket for live updates // WebSocket for live updates

View file

@ -117,6 +117,8 @@
</div> </div>
</main> </main>
<footer id="pageFooter" class="page-footer" style="left:0"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/login.js"></script> <script src="/static/js/login.js"></script>
</body> </body>

View file

@ -48,6 +48,8 @@
<div id="map"></div> <div id="map"></div>
</main> </main>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/map.js"></script> <script src="/static/js/map.js"></script>

View file

@ -78,6 +78,8 @@
</div> </div>
</main> </main>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/packets.js"></script> <script src="/static/js/packets.js"></script>

View file

@ -98,6 +98,10 @@
<div class="mb-3"> <div class="mb-3">
<label for="jobCommand" class="form-label" id="jobCommandLabel">Kommando</label> <label for="jobCommand" class="form-label" id="jobCommandLabel">Kommando</label>
<input type="text" class="form-control" id="jobCommand" placeholder="/weather" required> <input type="text" class="form-control" id="jobCommand" placeholder="/weather" required>
<div id="varHints" class="d-none mt-1">
<small class="text-body-secondary me-1">Variablen:</small>
<span id="varBadges"></span>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-8 mb-3"> <div class="col-8 mb-3">
@ -124,6 +128,8 @@
</div> </div>
</div> </div>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/scheduler.js"></script> <script src="/static/js/scheduler.js"></script>

View file

@ -148,6 +148,8 @@
</div> </div>
</main> </main>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/settings.js"></script> <script src="/static/js/settings.js"></script>