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:
parent
0fd401a395
commit
997a29842f
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
version: "0.6.14"
|
version: "0.6.15"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
name: "MeshDD-Bot"
|
name: "MeshDD-Bot"
|
||||||
|
|
|
||||||
1
main.py
1
main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
.then(d => { if (d?.version) vl.textContent = `v${d.version}`; });
|
if (d?.version && vl) vl.textContent = `v${d.version}`;
|
||||||
}
|
_injectFooter(d?.version);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue