Files
meshcore-bot/modules/web_viewer/templates/base.html
T
Stacy Olivas a15827be8f usability: API Explorer tab, actionable error messages (USE-05, USE-06)
- USE-05: Add /api-explorer page listing all ~65 API endpoints in 9
  categories (System, Contacts, Mesh, Channels, Feeds, Radio, Admin,
  Maintenance, Config, Greeter) with method badges, descriptions, and
  curl example modal. Filter bar and collapse per section. Nav item
  added to base.html.

- USE-06: Three targeted error-message improvements:
  1. 500 handler now returns user-friendly HTML page (error.html) for
     browser requests and sanitized JSON for API/JSON requests instead
     of a bare string.
  2. Feed processed-items query failures promoted from logger.debug to
     logger.warning so operators see them in normal log output.
  3. Global JS fetch interceptor in base.html redirects to /login?next=
     on any 401 response, handling session expiry mid-page.

- Fix pre-existing test bug: test_reload_endpoint_success mock return
  value did not match actual code message from reload_config.
2026-04-16 18:33:40 -07:00

863 lines
33 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>
(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);
}
/* 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>
<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="/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"
onclick="zombieRecover()" 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"
onclick="radioOfflineClear()" type="button">
<i class="fas fa-check me-1"></i>Clear Offline Flag
</button>
</div>
</div>
</div>
</div>
<script>
// 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.');
});
}
// 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>
{% if version_info.tag %}
<span class="mx-1" style="color: var(--text-muted, #6c757d);"> {{ version_info.tag }}</span>
{% elif version_info.branch or version_info.commit or version_info.date %}
<span class="mx-1" style="color: var(--text-muted, #6c757d);">
{% if version_info.branch %}{{ version_info.branch }}{% endif %}
{% if version_info.commit %}{% if version_info.branch %} · {% endif %}<code class="text-muted" style="font-size: inherit;">{{ version_info.commit }}</code>{% endif %}
{% if version_info.date %}{% if version_info.branch or version_info.commit %} · {% endif %}{{ 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>
// 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', () => {
console.log('Connected to server');
this.connected = true;
this.updateConnectionStatus('Connected', 'connected');
this.lastActivity = new Date();
this.updateLastActivity();
});
this.socket.on('disconnect', (reason) => {
console.log('Disconnected from server:', reason);
this.connected = false;
this.updateConnectionStatus('Disconnected', 'disconnected');
});
this.socket.on('status', (data) => {
console.log('Server status:', data.message);
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', () => {
console.log('Pong received from server');
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');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
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>