mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-24 03:20:09 +00:00
Removes 26 console.log calls, including contacts.html dumping whole data structures and sample contact records on every render. Variables and callback parameters that existed only to feed those logs go with them (deviceTypes, anyNewDevices, an availableEdges debug block, and a realtime status handler whose body was nothing but a log), so no new no-unused-vars warnings are introduced — the count drops 60 to 59. console.warn and console.error are kept. One console.log survives in realtime.html: the decoder key-count line is a one-time startup message with real diagnostic value, and removing it would mean deleting two counters and their increments inside a loop. Also normalizes the mesh page's user-visible "Neighbours" strings to match the project's American spelling. The option value stays "neighbors", so evidence-mode filtering is untouched.
915 lines
34 KiB
HTML
915 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% block title %}MeshCore Bot Data Viewer{% endblock %}</title>
|
|
|
|
<!-- Manifest served from Flask static folder -->
|
|
<link rel="manifest" href="{{ url_for('static', filename='ico/site.webmanifest') }}">
|
|
|
|
<!-- Apply theme immediately to prevent flash -->
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
(function () {
|
|
const storedTheme = localStorage.getItem('theme');
|
|
const systemPreference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
const theme = storedTheme || systemPreference;
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Bootstrap 5.1.3 -->
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
|
|
<!-- Font Awesome for icons -->
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
|
|
<!-- Custom styles -->
|
|
<style>
|
|
/* CSS Variables for Light Mode (Default) */
|
|
:root {
|
|
--bg-color: #ffffff;
|
|
--bg-secondary: #f8f9fa;
|
|
--bg-tertiary: #e9ecef;
|
|
--text-color: #212529;
|
|
--text-muted: #6c757d;
|
|
--border-color: #dee2e6;
|
|
--card-bg: #ffffff;
|
|
--card-header-bg: #f8f9fa;
|
|
--footer-bg: #f8f9fa;
|
|
--log-entry-bg: #f8f9fa;
|
|
--connection-info-bg: #e9ecef;
|
|
--shadow-color: rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* CSS Variables for Dark Mode */
|
|
[data-theme="dark"] {
|
|
--bg-color: #1a1a1a;
|
|
--bg-secondary: #2d2d2d;
|
|
--bg-tertiary: #3d3d3d;
|
|
--text-color: #e9ecef;
|
|
--text-muted: #adb5bd;
|
|
--border-color: #495057;
|
|
--card-bg: #2d2d2d;
|
|
--card-header-bg: #3d3d3d;
|
|
--footer-bg: #2d2d2d;
|
|
--log-entry-bg: #2d2d2d;
|
|
--connection-info-bg: #3d3d3d;
|
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
/* Apply theme variables */
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
transition: background-color 0.3s ease, color 0.3s ease;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-weight: bold;
|
|
color: #007bff !important;
|
|
}
|
|
|
|
.status-indicator {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.status-connected {
|
|
background-color: #28a745;
|
|
}
|
|
|
|
.status-disconnected {
|
|
background-color: #dc3545;
|
|
}
|
|
|
|
.status-warning {
|
|
background-color: #ffc107;
|
|
}
|
|
|
|
.log-entry {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.9em;
|
|
margin-bottom: 5px;
|
|
padding: 8px;
|
|
border-left: 3px solid #007bff;
|
|
background-color: var(--log-entry-bg);
|
|
border-radius: 0 5px 5px 0;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.command-entry {
|
|
border-left-color: #28a745;
|
|
}
|
|
|
|
.packet-entry {
|
|
border-left-color: #ffc107;
|
|
}
|
|
|
|
.connection-info {
|
|
background-color: var(--connection-info-bg);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.card {
|
|
box-shadow: 0 2px 4px var(--shadow-color);
|
|
border: none;
|
|
background-color: var(--card-bg);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.card-header {
|
|
background-color: var(--card-header-bg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-weight: 600;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.card-body {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.btn-group .btn {
|
|
margin-right: 5px;
|
|
}
|
|
|
|
.table-responsive {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Dark mode table styles */
|
|
[data-theme="dark"] .table {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table-striped>tbody>tr:nth-of-type(odd)>td {
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
[data-theme="dark"] .table-hover>tbody>tr:hover>td {
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.badge {
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.error {
|
|
color: #dc3545;
|
|
background-color: #f8d7da;
|
|
border: 1px solid #f5c6cb;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
[data-theme="dark"] .error {
|
|
color: #f5c6cb;
|
|
background-color: #721c24;
|
|
border-color: #842029;
|
|
}
|
|
|
|
.success {
|
|
color: #155724;
|
|
background-color: #d4edda;
|
|
border: 1px solid #c3e6cb;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
[data-theme="dark"] .success {
|
|
color: #d1e7dd;
|
|
background-color: #0f5132;
|
|
border-color: #146c43;
|
|
}
|
|
|
|
/* Dark mode toggle button */
|
|
.dark-mode-toggle {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, 0.85);
|
|
font-size: 1.2rem;
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
border-radius: 0.375rem;
|
|
transition: background-color 0.2s ease, color 0.2s ease;
|
|
}
|
|
|
|
.dark-mode-toggle:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
color: #ffffff;
|
|
}
|
|
|
|
.dark-mode-toggle:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
/* Dark mode form controls */
|
|
[data-theme="dark"] .form-control,
|
|
[data-theme="dark"] .form-select {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .form-control:focus,
|
|
[data-theme="dark"] .form-select:focus {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: #007bff;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .form-control:disabled,
|
|
[data-theme="dark"] .form-control[readonly],
|
|
[data-theme="dark"] .form-select:disabled {
|
|
background-color: var(--bg-color);
|
|
border-color: var(--border-color);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Dark mode text muted */
|
|
[data-theme="dark"] .text-muted {
|
|
color: var(--text-muted) !important;
|
|
}
|
|
|
|
/* Dark mode borders */
|
|
[data-theme="dark"] .border {
|
|
border-color: var(--border-color) !important;
|
|
}
|
|
|
|
/* Dark mode alerts */
|
|
[data-theme="dark"] .alert-info {
|
|
background-color: #084298;
|
|
border-color: #0a58ca;
|
|
color: #cfe2ff;
|
|
}
|
|
|
|
[data-theme="dark"] .alert-warning {
|
|
background-color: #664d03;
|
|
border-color: #997404;
|
|
color: #fffbeb;
|
|
}
|
|
|
|
[data-theme="dark"] .alert-danger {
|
|
background-color: #842029;
|
|
border-color: #b02a37;
|
|
color: #f8d7da;
|
|
}
|
|
|
|
[data-theme="dark"] .alert-success {
|
|
background-color: #0f5132;
|
|
border-color: #146c43;
|
|
color: #d1e7dd;
|
|
}
|
|
|
|
/* Dark mode modal styling */
|
|
[data-theme="dark"] .modal-content {
|
|
background-color: var(--card-bg);
|
|
color: var(--text-color);
|
|
border-color: var(--border-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal-header {
|
|
background-color: var(--card-header-bg);
|
|
border-bottom-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal-title {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal-body {
|
|
background-color: var(--card-bg);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal-footer {
|
|
background-color: var(--card-bg);
|
|
border-top-color: var(--border-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal-backdrop {
|
|
background-color: rgba(0, 0, 0, 0.7);
|
|
}
|
|
|
|
/* Dark mode code/pre elements in modals */
|
|
[data-theme="dark"] .modal pre,
|
|
[data-theme="dark"] .modal code {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
border-color: var(--border-color);
|
|
}
|
|
|
|
[data-theme="dark"] .modal .bg-light {
|
|
background-color: var(--bg-tertiary) !important;
|
|
color: var(--text-color) !important;
|
|
}
|
|
|
|
/* Dark mode dropdown menus */
|
|
[data-theme="dark"] .dropdown-menu {
|
|
background-color: var(--card-bg);
|
|
border-color: var(--border-color);
|
|
box-shadow: 0 0.5rem 1rem var(--shadow-color);
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-item {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-item:hover,
|
|
[data-theme="dark"] .dropdown-item:focus {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-item.text-danger {
|
|
color: #f5c2c7;
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-item.text-danger:hover,
|
|
[data-theme="dark"] .dropdown-item.text-danger:focus {
|
|
color: #f8d7da;
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-header {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
[data-theme="dark"] .dropdown-divider {
|
|
border-top-color: var(--border-color);
|
|
}
|
|
|
|
/* Dark mode accordion styling */
|
|
[data-theme="dark"] .accordion-item {
|
|
background-color: var(--card-bg);
|
|
border-color: var(--border-color);
|
|
}
|
|
|
|
[data-theme="dark"] .accordion-button {
|
|
background-color: var(--card-header-bg);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .accordion-button:not(.collapsed) {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .accordion-button:focus {
|
|
box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25);
|
|
}
|
|
|
|
[data-theme="dark"] .accordion-body {
|
|
background-color: var(--card-bg);
|
|
color: var(--text-color);
|
|
}
|
|
</style>
|
|
|
|
{% block extra_css %}{% endblock %}
|
|
</head>
|
|
|
|
<body>
|
|
<!-- Navigation -->
|
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="/">
|
|
<i class="fas fa-satellite-dish"></i> {{ bot_name|default('MeshCore Bot') }}
|
|
</a>
|
|
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
|
|
<div class="collapse navbar-collapse" id="navbarNav">
|
|
<ul class="navbar-nav me-auto">
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/">
|
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/realtime">
|
|
<i class="fas fa-broadcast-tower"></i> Real-time
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/contacts">
|
|
<i class="fas fa-users"></i> Contacts
|
|
</a>
|
|
</li>
|
|
{% if multibyte_monitor_enabled %}
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/multibyte-rollout">
|
|
<i class="fas fa-code-branch"></i> Multibyte
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/mesh">
|
|
<i class="fas fa-project-diagram"></i> Mesh Graph
|
|
</a>
|
|
</li>
|
|
{% if greeter_enabled %}
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/greeter">
|
|
<i class="fas fa-hand-sparkles"></i> Greeter
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if feed_manager_enabled %}
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/feeds">
|
|
<i class="fas fa-rss"></i> Feeds
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/radio">
|
|
<i class="fas fa-broadcast-tower"></i> Radio
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/config">
|
|
<i class="fas fa-cog"></i> Config
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/plugins">
|
|
<i class="fas fa-puzzle-piece"></i> Plugins
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/logs">
|
|
<i class="fas fa-file-alt"></i> Logs
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Dark Mode Toggle and Connection Status -->
|
|
<div class="navbar-nav d-flex align-items-center">
|
|
<div class="nav-item me-3">
|
|
<button class="dark-mode-toggle" id="dark-mode-toggle" aria-label="Toggle dark mode"
|
|
title="Toggle dark mode">
|
|
<i class="fas fa-moon" id="dark-mode-icon"></i>
|
|
</button>
|
|
</div>
|
|
<div class="nav-item">
|
|
<span class="navbar-text">
|
|
<span class="status-indicator" id="connection-status"></span>
|
|
<span id="connection-text">Disconnected</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Bot Initializing Banner (shown while bot is connecting to radio at startup) -->
|
|
<div id="init-banner"
|
|
class="alert alert-info mb-0 rounded-0 border-0 border-bottom border-info{% if not bot_initializing %} d-none{% endif %}"
|
|
role="alert" style="border-width: 2px !important;">
|
|
<div class="container-fluid">
|
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
|
<strong>Bot Initializing</strong> — connecting to radio hardware. The web viewer is available but bot
|
|
commands are not yet active.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zombie Radio Banner (persistent — shown on all pages when radio is in zombie state) -->
|
|
<div id="zombie-banner"
|
|
class="alert alert-danger mb-0 rounded-0 border-0 border-bottom border-danger{% if not radio_zombie %} d-none{% endif %}"
|
|
role="alert" style="border-width: 2px !important;">
|
|
<div class="container-fluid">
|
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
|
<div>
|
|
<i class="fas fa-skull-crossbones me-2"></i>
|
|
<strong>Radio Zombie State Detected</strong> — The bot radio firmware is unresponsive.
|
|
A physical <strong>power cycle</strong> of the radio hardware is required.
|
|
Disconnect/reconnect will <em>not</em> fix this.
|
|
<span id="zombie-since-span"
|
|
class="ms-3 text-danger-emphasis small opacity-75{% if not radio_zombie_since %} d-none{% endif %}">
|
|
<i class="fas fa-clock me-1"></i>Since: <span id="zombie-since-text">{{ radio_zombie_since or ''
|
|
}}</span>
|
|
</span>
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center flex-shrink-0">
|
|
<span class="small opacity-75">After power cycling:</span>
|
|
<button id="zombie-recover-btn" class="btn btn-warning btn-sm fw-semibold"
|
|
type="button">
|
|
<i class="fas fa-power-off me-1"></i>Restart Bot Processing
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Radio Offline Banner (shown when repeated send timeouts have entered the offline state) -->
|
|
<div id="offline-banner"
|
|
class="alert alert-warning mb-0 rounded-0 border-0 border-bottom border-warning{% if not radio_offline %} d-none{% endif %}"
|
|
role="alert" style="border-width: 2px !important;">
|
|
<div class="container-fluid">
|
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
|
<div>
|
|
<i class="fas fa-plug me-2"></i>
|
|
<strong>Radio Offline</strong> — the bot cannot send outbound messages to the mesh.
|
|
Check radio power and connection. Inbound packets may still be arriving normally.
|
|
<span id="offline-since-span"
|
|
class="ms-3 text-warning-emphasis small opacity-75{% if not radio_offline_since %} d-none{% endif %}">
|
|
<i class="fas fa-clock me-1"></i>Since: <span id="offline-since-text">{{ radio_offline_since or
|
|
'' }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center flex-shrink-0">
|
|
<span class="small opacity-75">Once radio is fixed:</span>
|
|
<button id="offline-clear-btn" class="btn btn-dark btn-sm fw-semibold"
|
|
type="button">
|
|
<i class="fas fa-check me-1"></i>Clear Offline Flag
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
// Global 401 interceptor: redirect to login when session expires mid-page.
|
|
// Wraps window.fetch so all AJAX calls in every template are covered.
|
|
(function () {
|
|
var _origFetch = window.fetch;
|
|
window.fetch = function (url, options) {
|
|
return _origFetch.apply(this, arguments).then(function (response) {
|
|
if (response.status === 401 && typeof url === 'string' && url.startsWith('/api/')) {
|
|
var next = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = '/login?next=' + next;
|
|
}
|
|
return response;
|
|
});
|
|
};
|
|
})();
|
|
|
|
// Banner action: zombie recover
|
|
function zombieRecover() {
|
|
var btn = document.getElementById('zombie-recover-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Restarting\u2026';
|
|
fetch('/api/admin/zombie-recover', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.success) {
|
|
var b = document.getElementById('zombie-banner');
|
|
b.className = 'alert alert-success mb-0 rounded-0 border-0 border-bottom border-success';
|
|
b.innerHTML = '<div class="container-fluid"><i class="fas fa-check-circle me-2"></i>' +
|
|
'<strong>Reconnect scheduled.</strong> Refresh this page in a few seconds to confirm the radio is back online.</div>';
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-power-off me-1"></i>Restart Bot Processing';
|
|
alert('Error: ' + (data.error || 'Unknown error \u2014 check server logs'));
|
|
}
|
|
})
|
|
.catch(function () {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-power-off me-1"></i>Restart Bot Processing';
|
|
alert('Network error \u2014 could not reach the server.');
|
|
});
|
|
}
|
|
|
|
// Banner action: radio offline clear
|
|
function radioOfflineClear() {
|
|
var btn = document.getElementById('offline-clear-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Clearing\u2026';
|
|
fetch('/api/admin/radio-offline-clear', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.success) {
|
|
var b = document.getElementById('offline-banner');
|
|
b.className = 'alert alert-success mb-0 rounded-0 border-0 border-bottom border-success';
|
|
b.innerHTML = '<div class="container-fluid"><i class="fas fa-check-circle me-2"></i>' +
|
|
'<strong>Offline flag cleared.</strong> The bot will resume outbound sends. Refresh to confirm.</div>';
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-check me-1"></i>Clear Offline Flag';
|
|
alert('Error: ' + (data.error || 'Unknown error \u2014 check server logs'));
|
|
}
|
|
})
|
|
.catch(function () {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-check me-1"></i>Clear Offline Flag';
|
|
alert('Network error \u2014 could not reach the server.');
|
|
});
|
|
}
|
|
|
|
document.getElementById('zombie-recover-btn')?.addEventListener('click', zombieRecover);
|
|
document.getElementById('offline-clear-btn')?.addEventListener('click', radioOfflineClear);
|
|
|
|
// Live banner polling — updates all banners every 10s without a page reload
|
|
(function () {
|
|
function applyBannerStatus(d) {
|
|
var show = function (id) { var el = document.getElementById(id); if (el) el.classList.remove('d-none'); };
|
|
var hide = function (id) { var el = document.getElementById(id); if (el) el.classList.add('d-none'); };
|
|
var setText = function (id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; };
|
|
|
|
d.bot_initializing ? show('init-banner') : hide('init-banner');
|
|
|
|
if (d.radio_zombie) {
|
|
show('zombie-banner');
|
|
if (d.radio_zombie_since) { setText('zombie-since-text', d.radio_zombie_since); show('zombie-since-span'); }
|
|
else { hide('zombie-since-span'); }
|
|
} else {
|
|
hide('zombie-banner');
|
|
}
|
|
|
|
if (d.radio_offline) {
|
|
show('offline-banner');
|
|
if (d.radio_offline_since) { setText('offline-since-text', d.radio_offline_since); show('offline-since-span'); }
|
|
else { hide('offline-since-span'); }
|
|
} else {
|
|
hide('offline-banner');
|
|
}
|
|
}
|
|
|
|
function pollBanners() {
|
|
fetch('/api/banner-status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(applyBannerStatus)
|
|
.catch(function () { }); // silent — server may be restarting
|
|
}
|
|
|
|
setInterval(pollBanners, 10000);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Main Content -->
|
|
<div class="container-fluid mt-4">
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
|
|
<!-- Footer links (bottom of page, visible when scrolled down) -->
|
|
<footer class="text-center py-2" style="font-size: 0.7rem;">
|
|
<a href="https://github.com/agessaman/meshcore-bot" target="_blank" rel="noopener noreferrer"
|
|
style="color: var(--text-muted, #6c757d); text-decoration: none;">meshcore-bot</a>
|
|
{# 'display' already folds in branch/tag/commit — same string !version
|
|
prints. Only the date is added, since display never carries it. #}
|
|
{% if version_info.display %}
|
|
<span class="mx-1" style="color: var(--text-muted, #6c757d);">
|
|
{{ version_info.display }}
|
|
{% if version_info.date %} · {{ version_info.date }}{% endif %}
|
|
</span>
|
|
{% endif %}
|
|
<span class="mx-1" style="color: var(--text-muted, #6c757d);"> for </span>
|
|
<a href="https://meshcore.co.uk/index.html" target="_blank" rel="noopener noreferrer"
|
|
style="color: var(--text-muted, #6c757d); text-decoration: none;">MeshCore</a>
|
|
</footer>
|
|
|
|
<!-- Bootstrap JS -->
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
|
|
<!-- Socket.IO Client -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
|
|
|
|
<!-- Moment.js for date formatting -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
|
|
|
|
<!-- Chart.js for graphs -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
|
|
<!-- SHA-256 for channel key derivation (works in non-HTTPS contexts) -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
|
|
|
<!-- Base JavaScript -->
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
// Global connection management
|
|
class ModernConnectionManager {
|
|
constructor() {
|
|
this.socket = null;
|
|
this.connected = false;
|
|
this.lastActivity = null;
|
|
this.pingInterval = null;
|
|
|
|
this.initializeSocket();
|
|
this.startPingInterval();
|
|
}
|
|
|
|
initializeSocket() {
|
|
// Use io() without forceNew so that realtime.html's io() shares
|
|
// this same manager/socket. forceNew created a second independent
|
|
// connection that interfered with the realtime-page socket.
|
|
this.socket = io({
|
|
transports: ['websocket', 'polling'],
|
|
timeout: 5000
|
|
});
|
|
|
|
this.setupSocketEvents();
|
|
}
|
|
|
|
setupSocketEvents() {
|
|
this.socket.on('connect', () => {
|
|
this.connected = true;
|
|
this.updateConnectionStatus('Connected', 'connected');
|
|
this.lastActivity = new Date();
|
|
this.updateLastActivity();
|
|
});
|
|
|
|
this.socket.on('disconnect', () => {
|
|
this.connected = false;
|
|
this.updateConnectionStatus('Disconnected', 'disconnected');
|
|
});
|
|
|
|
this.socket.on('status', (data) => {
|
|
this.showNotification(data.message, 'info');
|
|
});
|
|
|
|
this.socket.on('error', (data) => {
|
|
console.error('Server error:', data.message);
|
|
this.showNotification(`Error: ${data.message}`, 'danger');
|
|
});
|
|
|
|
// Modern ping/pong pattern
|
|
this.socket.on('pong', () => {
|
|
this.lastActivity = new Date();
|
|
this.updateLastActivity();
|
|
});
|
|
}
|
|
|
|
startPingInterval() {
|
|
this.pingInterval = setInterval(() => {
|
|
if (this.socket && this.socket.connected) {
|
|
this.socket.emit('ping');
|
|
}
|
|
}, 20000);
|
|
}
|
|
|
|
stopPingInterval() {
|
|
if (this.pingInterval) {
|
|
clearInterval(this.pingInterval);
|
|
this.pingInterval = null;
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(text, status) {
|
|
const statusElement = document.getElementById('connection-text');
|
|
const indicatorElement = document.getElementById('connection-status');
|
|
|
|
if (statusElement) {
|
|
statusElement.textContent = text;
|
|
}
|
|
if (indicatorElement) {
|
|
indicatorElement.className = `status-indicator status-${status}`;
|
|
}
|
|
}
|
|
|
|
updateLastActivity() {
|
|
if (this.lastActivity) {
|
|
const lastUpdateElement = document.getElementById('last-update');
|
|
if (lastUpdateElement) {
|
|
lastUpdateElement.textContent = moment(this.lastActivity).format('YYYY-MM-DD HH:mm:ss');
|
|
}
|
|
}
|
|
}
|
|
|
|
showNotification(message, type = 'info') {
|
|
// Create notification element
|
|
const notification = document.createElement('div');
|
|
const safeType = ['info', 'success', 'warning', 'danger'].includes(type)
|
|
? type
|
|
: 'info';
|
|
notification.className = `alert alert-${safeType} alert-dismissible fade show position-fixed`;
|
|
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
|
|
|
|
const notificationText = document.createElement('span');
|
|
notificationText.textContent = String(message ?? '');
|
|
const closeButton = document.createElement('button');
|
|
closeButton.type = 'button';
|
|
closeButton.className = 'btn-close';
|
|
closeButton.setAttribute('data-bs-dismiss', 'alert');
|
|
closeButton.setAttribute('aria-label', 'Close');
|
|
notification.append(notificationText, closeButton);
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Auto-remove after 3 seconds
|
|
setTimeout(() => {
|
|
if (notification.parentNode) {
|
|
notification.parentNode.removeChild(notification);
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// Update timestamp function
|
|
function updateTimestamp() {
|
|
const now = new Date();
|
|
const timestamp = now.toISOString().replace('T', ' ').substring(0, 19);
|
|
const lastUpdateElement = document.getElementById('last-update');
|
|
if (lastUpdateElement) {
|
|
lastUpdateElement.textContent = timestamp;
|
|
}
|
|
}
|
|
|
|
// Dark Mode Management
|
|
class DarkModeManager {
|
|
constructor() {
|
|
this.theme = this.getStoredTheme() || this.getSystemPreference();
|
|
this.applyTheme();
|
|
this.setupToggle();
|
|
}
|
|
|
|
getStoredTheme() {
|
|
return localStorage.getItem('theme');
|
|
}
|
|
|
|
getSystemPreference() {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
|
|
setTheme(theme) {
|
|
this.theme = theme;
|
|
localStorage.setItem('theme', theme);
|
|
this.applyTheme();
|
|
}
|
|
|
|
applyTheme() {
|
|
const root = document.documentElement;
|
|
if (this.theme === 'dark') {
|
|
root.setAttribute('data-theme', 'dark');
|
|
const icon = document.getElementById('dark-mode-icon');
|
|
if (icon) {
|
|
icon.classList.remove('fa-moon');
|
|
icon.classList.add('fa-sun');
|
|
}
|
|
} else {
|
|
root.setAttribute('data-theme', 'light');
|
|
const icon = document.getElementById('dark-mode-icon');
|
|
if (icon) {
|
|
icon.classList.remove('fa-sun');
|
|
icon.classList.add('fa-moon');
|
|
}
|
|
}
|
|
}
|
|
|
|
toggleTheme() {
|
|
const newTheme = this.theme === 'dark' ? 'light' : 'dark';
|
|
this.setTheme(newTheme);
|
|
}
|
|
|
|
setupToggle() {
|
|
const toggle = document.getElementById('dark-mode-toggle');
|
|
if (toggle) {
|
|
toggle.addEventListener('click', () => {
|
|
this.toggleTheme();
|
|
});
|
|
}
|
|
|
|
// Listen for system theme changes
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
// Only apply system preference if user hasn't set a preference
|
|
if (!this.getStoredTheme()) {
|
|
this.setTheme(e.matches ? 'dark' : 'light');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create connection manager synchronously so page scripts in the extra_js
|
|
// block can share the same socket without calling io() a second time.
|
|
window.connectionManager = new ModernConnectionManager();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize dark mode manager first
|
|
window.darkModeManager = new DarkModeManager();
|
|
|
|
// Update timestamp immediately and every second
|
|
updateTimestamp();
|
|
setInterval(updateTimestamp, 1000);
|
|
});
|
|
</script>
|
|
|
|
<script src="{{ url_for('static', filename='js/channel_operations.js') }}"></script>
|
|
{% block extra_js %}{% endblock %}
|
|
</body>
|
|
|
|
</html>
|