Files
meshcore-bot/modules/web_viewer/templates/base.html
T
agessaman 8d1c5ea9c0 build(web_viewer): vendor the last CDN libraries and keep them updated with Dependabot
Bootstrap 5.1.3, Font Awesome 6.0.0, Socket.IO 4.7.2 and Chart.js 4.4.0 are now served from static/vendor/ at the versions the CDNs served, so every page works on a LAN with no internet. Only map tiles still need it. The Content-Security-Policy drops jsDelivr and cdnjs.

Every vendored package, the map stack and login fonts included, is pinned in scripts/web-vendor/package.json and its lockfile. sync.mjs copies the listed files into static/, strips the source-map comment, empties each directory it owns first, and regenerates the version table in static/vendor/README.md; it reproduces the previously vendored files byte for byte. Font Awesome ships only its woff2 fonts, which every supported browser takes before the .ttf.

Dependabot watches that directory: security fixes as advisories land, grouped minor and patch releases monthly, majors one per PR. Dependabot can't regenerate the vendored files, so vendor-refresh.yml adds them to its PRs as a second commit and dispatches the tests, since a GITHUB_TOKEN push doesn't trigger them. It runs on pull_request_target, so it uses the base branch's sync.mjs, spells out its commands instead of running the PR's npm scripts, and installs with install scripts disabled. A new CI job re-runs the sync and fails if anything under static/ changes.

Tests now check that no template loads a script, stylesheet or font from another host, that every vendored file a template names exists, and that the CSP allows no CDN host. In Chrome, 12 pages load with no external request, failed load or console error.
2026-10-24 12:02:40 -07:00

1317 lines
52 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, Font Awesome and the other libraries are vendored under static/vendor/ (versions in its README). -->
<link href="{{ url_for('static', filename='vendor/bootstrap/bootstrap.min.css') }}" rel="stylesheet">
<!-- Font Awesome for icons -->
<link href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}" rel="stylesheet">
<!-- Custom styles -->
<style>
/* CSS Variables for Light Mode (Default) */
:root {
/* One monospace stack for logs, keys, hex and code. Bootstrap's
.font-monospace, code and pre read it through --bs-font-monospace. */
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--bs-font-monospace: var(--font-mono);
--bg-color: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--text-color: #212529;
--text-muted: #5f666d;
--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);
/* Status and accent colors used as text. Bootstrap's own text-*
colors are background tones and fail contrast as text; these
pass 4.5:1 on paper, paper-raised and paper-sunk. */
--text-success: #146c43;
--text-warning: #7a5d02;
--text-danger: #b02a37;
--text-info: #0a6c80;
--text-primary: #0d6efd;
--link-color: #0d6efd;
--code-color: #ab296a;
}
/* 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);
/* Pass 4.5:1 on night, night-raised and night-sunk. */
--text-success: #75b798;
--text-warning: #ffda6a;
--text-danger: #f1aeb5;
--text-info: #6edff6;
--text-primary: #6ea8fe;
--link-color: #6ea8fe;
--code-color: #e685b5;
}
/* 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: #0d6efd !important;
}
/* Readout colors for stat tiles: each tile reads in its own hue so a
row of counts can be told apart at a glance. Text-safe on both
themes; a tile showing a problem uses --text-danger instead. */
:root {
--readout-blue: #0a58ca;
--readout-teal: #0f7a5f;
--readout-violet: #6d28d9;
--readout-orange: #b4501a;
}
[data-theme="dark"] {
--readout-blue: #6ea8fe;
--readout-teal: #4fd1ae;
--readout-violet: #c4b5fd;
--readout-orange: #f4a26b;
}
.readout-blue { color: var(--readout-blue); }
.readout-teal { color: var(--readout-teal); }
.readout-violet { color: var(--readout-violet); }
.readout-orange { color: var(--readout-orange); }
.readout-muted { color: var(--text-muted); }
.readout-danger { color: var(--text-danger); }
/* Row actions shared by table pages: each action keeps its own color
and a light outline, so a row's actions are found and told apart at
a glance without a wall of solid buttons. */
:root {
--action-star: #a36b00;
--action-geo: #0f7a5f;
--action-play: #146c43;
--action-pause: #8a5a00;
}
[data-theme="dark"] {
--action-star: #ffd65a;
--action-geo: #4fd1ae;
--action-play: #75b798;
--action-pause: #ffda6a;
}
.row-action {
--action: var(--link-color);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-width: 2rem;
height: 2rem;
padding: 0 0.45rem;
font-size: 0.875rem;
white-space: nowrap;
color: var(--action);
background-color: color-mix(in srgb, var(--action) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--action) 35%, transparent);
border-radius: 0.25rem;
transition: background-color 0.12s ease-out, border-color 0.12s ease-out;
}
.row-action:hover,
.row-action:focus-visible {
color: var(--action);
background-color: color-mix(in srgb, var(--action) 18%, transparent);
border-color: color-mix(in srgb, var(--action) 60%, transparent);
}
.row-action:focus-visible {
outline: 2px solid var(--action);
outline-offset: 1px;
}
.row-action--view { --action: var(--text-info); }
.row-action--edit { --action: var(--link-color); }
.row-action--play { --action: var(--action-play); }
.row-action--pause { --action: var(--action-pause); }
.row-action--danger { --action: var(--text-danger); }
/* Category chips: tinted, readable, not the status lamps */
.tag-chip {
display: inline-block;
padding: 0.05rem 0.5rem;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
border-radius: 999px;
color: var(--chip-ink, var(--readout-blue));
background-color: color-mix(in srgb, var(--chip-ink, var(--readout-blue)) 14%, transparent);
}
.tag-chip--violet { --chip-ink: var(--readout-violet); }
.tag-chip--teal { --chip-ink: var(--readout-teal); }
.tag-chip--orange { --chip-ink: var(--readout-orange); }
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-connected {
background-color: #198754;
}
.status-disconnected {
background-color: #dc3545;
}
.status-warning {
background-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);
}
/* Bootstrap's striping sets a light --bs-table-striped-color on the
cell as well as a background. Overriding only the background left
#212529 text on a #3d3d3d row in dark mode — about 1.3:1. */
[data-theme="dark"] .table-striped>tbody>tr:nth-of-type(odd)>td {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="dark"] .table-hover>tbody>tr:hover>td {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
.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: #0d6efd;
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);
}
/* Muted and status text follow the theme in both themes */
.text-muted,
.form-text {
color: var(--text-muted) !important;
}
/* On the console-black table headers and navbar, muted text uses the
night tone in either theme. */
.table-dark .text-muted,
.navbar-dark .text-muted {
color: #adb5bd !important;
}
.alert code { color: inherit; }
.text-success { color: var(--text-success) !important; }
.text-warning { color: var(--text-warning) !important; }
.text-danger { color: var(--text-danger) !important; }
.text-info { color: var(--text-info) !important; }
.text-primary { color: var(--text-primary) !important; }
code { color: var(--code-color); }
/* Solid info and warning fills are light, so they carry dark text
(Bootstrap 5.1 gives every badge white text). */
.badge.bg-info,
.badge.bg-warning {
color: #212529 !important;
}
[data-theme="dark"] a:not(.btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item) {
color: var(--link-color);
}
/* Outline buttons: Bootstrap's outline text colors are its fill tones,
which fail as text on raised surfaces and on the dark theme. */
.btn-outline-secondary {
color: var(--text-muted);
}
.btn-outline-secondary:hover,
.btn-outline-secondary.active {
color: #fff;
background-color: #6c757d;
}
.btn-outline-danger {
color: var(--text-danger);
}
.btn-outline-danger:hover,
.btn-outline-danger.active {
color: #fff;
}
[data-theme="dark"] .btn-outline-primary {
color: var(--text-primary);
}
[data-theme="dark"] .btn-outline-primary:hover,
[data-theme="dark"] .btn-outline-primary.active {
color: #fff;
}
.btn-outline-warning {
color: var(--text-warning);
}
.btn-outline-warning:hover,
.btn-outline-warning.active {
color: #212529;
}
[data-theme="dark"] .btn-link {
color: var(--link-color);
}
/* Dark mode borders. The directional utilities need naming too — the
all-sides rule does not cover them, so a .border-start divider drew
Bootstrap's light #dee2e6 straight onto a dark card. */
[data-theme="dark"] .border,
[data-theme="dark"] .border-top,
[data-theme="dark"] .border-end,
[data-theme="dark"] .border-bottom,
[data-theme="dark"] .border-start {
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(13, 110, 253, 0.25);
}
[data-theme="dark"] .accordion-body {
background-color: var(--card-bg);
color: var(--text-color);
}
/* Status banners: the "Since" time sits beside the message on wide
screens and on its own line on phones. d-none still wins. */
.banner-since {
margin-left: 1rem;
}
@media (max-width: 575.98px) {
.banner-since {
display: block;
margin-left: 0;
}
}
/* Card headers that pair a title with controls wrap the controls onto
their own row when both don't fit, instead of squeezing the title
into a column. */
.card-header.d-flex {
flex-wrap: wrap;
gap: 0.5rem;
}
/* Touch screens: Bootstrap's compact controls are 29-38px tall and its
checkboxes 16px, too small to hit reliably. On a coarse pointer the
standard controls grow to 44px and the small ones to 36px, keeping
the dense layout on desktop untouched. */
@media (pointer: coarse) {
.btn,
.form-control,
.form-select {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.btn:not(.btn-link) {
min-width: 44px;
}
.btn-sm,
.btn-group-sm > .btn,
.input-group-sm > .btn,
.form-control-sm,
.form-select-sm,
.input-group-sm > .form-control,
.input-group-sm > .form-select,
.input-group-sm > .input-group-text {
padding-top: 0.45rem;
padding-bottom: 0.45rem;
}
.btn-sm:not(.btn-link),
.btn-group-sm > .btn:not(.btn-link) {
min-width: 36px;
}
.input-group-text {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.form-check {
min-height: 1.5rem;
padding-left: 2rem;
}
.form-check .form-check-input {
margin-left: -2rem;
}
.form-check-input {
width: 1.5rem;
height: 1.5rem;
margin-top: 0;
vertical-align: middle;
}
.form-check-label {
line-height: 1.5rem;
}
.form-switch {
padding-left: 3.25rem;
}
.form-switch .form-check-input {
width: 2.75rem;
margin-left: -3.25rem;
}
.form-check-inline {
margin-right: 1.25rem;
}
.dropdown-item {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.navbar-nav .nav-link {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.btn-close {
padding: 0.75rem;
}
summary {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.leaflet-container.leaflet-touch .leaflet-bar a {
width: 40px;
height: 40px;
line-height: 40px;
}
}
/* Toasts float over the page in one stack, so a message never pushes
the content down and several messages don't pile on each other. */
.viewer-toasts {
position: fixed;
top: 4.5rem;
right: 1rem;
z-index: 1090;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: min(24rem, calc(100vw - 2rem));
pointer-events: none;
}
.viewer-toasts .alert {
margin: 0;
pointer-events: auto;
box-shadow: 0 0.5rem 1rem var(--shadow-color);
animation: viewer-toast-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes viewer-toast-in {
from { opacity: 0; transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.viewer-toasts .alert { animation: none; }
}
@media (max-width: 575.98px) {
.viewer-toasts {
top: auto;
right: 1rem;
bottom: 1rem;
left: 1rem;
width: auto;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body data-is-admin="{{ 'true' if (not auth_enabled or is_admin) else 'false' }}">
<!-- Navigation -->
<div class="viewer-toasts" id="viewer-toasts" aria-live="polite"></div>
<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" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<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 {{ 'active' if request.path == '/' }}" href="/">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
{% if not auth_enabled or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.path == '/realtime' }}" href="/realtime">
<i class="fas fa-broadcast-tower"></i> Real-time
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.path == '/contacts' }}" href="/contacts">
<i class="fas fa-users"></i> Contacts
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.path == '/mesh' }}" href="/mesh">
<i class="fas fa-project-diagram"></i> Mesh Graph
</a>
</li>
{% if multibyte_monitor_enabled and (not auth_enabled or is_admin) %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.path == '/multibyte-rollout' }}" href="/multibyte-rollout">
<i class="fas fa-code-branch"></i> Multibyte
</a>
</li>
{% endif %}
{% if not auth_enabled or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.path == '/logs' }}" href="/logs">
<i class="fas fa-file-alt"></i> Logs
</a>
</li>
{#- Configuration pages live behind one gear so the bar stays about
what the mesh is doing, not how the bot is set up. -#}
{% set settings_paths = ['/radio', '/schedule', '/greeter', '/feeds', '/region-warnings', '/plugins', '/config'] %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.path in settings_paths }}"
href="#" id="settingsDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-cog"></i> Settings
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="settingsDropdown">
<li>
<a class="dropdown-item {{ 'active' if request.path == '/radio' }}" href="/radio">
<i class="fas fa-broadcast-tower fa-fw me-2"></i>Radio
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.path == '/schedule' }}" href="/schedule">
<i class="fas fa-clock fa-fw me-2"></i>Scheduled Messages
</a>
</li>
{% if greeter_enabled %}
<li>
<a class="dropdown-item {{ 'active' if request.path == '/greeter' }}" href="/greeter">
<i class="fas fa-hand-sparkles fa-fw me-2"></i>Greeter
</a>
</li>
{% endif %}
{% if feed_manager_enabled %}
<li>
<a class="dropdown-item {{ 'active' if request.path == '/feeds' }}" href="/feeds">
<i class="fas fa-rss fa-fw me-2"></i>Feeds
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item {{ 'active' if request.path == '/region-warnings' }}" href="/region-warnings">
<i class="fas fa-globe fa-fw me-2"></i>Region Warnings
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.path == '/plugins' }}" href="/plugins">
<i class="fas fa-puzzle-piece fa-fw me-2"></i>Plugins
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item {{ 'active' if request.path == '/config' }}" href="/config">
<i class="fas fa-sliders-h fa-fw me-2"></i>Configuration
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<!-- Dark Mode Toggle and Connection Status -->
<div class="navbar-nav d-flex align-items-center">
{% if auth_enabled %}
<div class="nav-item me-3">
{% if is_admin %}
<a href="/logout" class="btn btn-sm btn-outline-light">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
{% else %}
<a href="/login" class="btn btn-sm btn-outline-light">
<i class="fas fa-sign-in-alt"></i> Admin Login
</a>
{% endif %}
</div>
{% endif %}
<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 py-2 py-sm-3{% 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> —<span class="d-none d-sm-inline"> The bot radio firmware is unresponsive.</span>
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="banner-since text-danger-emphasis small{% if not radio_zombie_since %} d-none{% endif %}">
<i class="fas fa-clock me-1"></i>Since: <time id="zombie-since-text" class="js-since" datetime="{{ radio_zombie_since or '' }}">{{ radio_zombie_since or '' }}</time>
</span>
</div>
{% if not auth_enabled or is_admin %}
<div class="d-flex gap-2 align-items-center flex-shrink-0">
<span class="small d-none d-sm-inline">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>
{% endif %}
</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 py-2 py-sm-3{% 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.
<span class="d-none d-sm-inline">Check radio power and connection. Inbound packets may still be arriving normally.</span>
<span id="offline-since-span"
class="banner-since text-warning-emphasis small{% if not radio_offline_since %} d-none{% endif %}">
<i class="fas fa-clock me-1"></i>Since: <time id="offline-since-text" class="js-since" datetime="{{ radio_offline_since or '' }}">{{ radio_offline_since or '' }}</time>
</span>
</div>
{% if not auth_enabled or is_admin %}
<div class="d-flex gap-2 align-items-center flex-shrink-0">
<span class="small d-none d-sm-inline">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>
{% endif %}
</div>
</div>
</div>
<script nonce="{{ g.csp_nonce }}">
// One toast stack for every page. `message` is a string or a node the
// caller has already filled with text; nothing is parsed as HTML.
window.viewerToast = function (message, kind, timeout) {
var host = document.getElementById('viewer-toasts');
if (!host) return;
var safeKind = ['info', 'success', 'warning', 'danger'].indexOf(kind) >= 0 ? kind : 'info';
var toast = document.createElement('div');
toast.className = 'alert alert-' + safeKind + ' alert-dismissible fade show';
if (safeKind === 'danger') toast.setAttribute('role', 'alert');
var body = message;
if (!(message instanceof Node)) {
body = document.createElement('span');
body.textContent = String(message === undefined || message === null ? '' : message);
}
var close = document.createElement('button');
close.type = 'button';
close.className = 'btn-close';
close.setAttribute('data-bs-dismiss', 'alert');
close.setAttribute('aria-label', 'Close');
toast.append(body, close);
host.appendChild(toast);
while (host.children.length > 4) host.firstElementChild.remove();
setTimeout(function () { toast.remove(); }, timeout || (safeKind === 'danger' ? 6000 : 3500));
};
// 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);
// Banner "Since" times arrive as UTC ISO strings. Show them in the
// viewer's own time zone with a relative age ("Oct 15, 7:42 AM · 2 days
// ago"), keeping the exact value in datetime= and the tooltip.
function formatSince(el, iso) {
if (!el) return;
el.setAttribute('datetime', iso || '');
var d = iso ? new Date(iso) : null;
if (!d || isNaN(d.getTime())) { el.textContent = iso || ''; el.removeAttribute('title'); return; }
var now = new Date();
var sameDay = d.toDateString() === now.toDateString();
var abs = d.toLocaleString(undefined, sameDay
? { hour: 'numeric', minute: '2-digit' }
: { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
var secs = Math.round((d.getTime() - now.getTime()) / 1000);
var units = [['day', 86400], ['hour', 3600], ['minute', 60]];
var rel = 'just now';
for (var i = 0; i < units.length; i++) {
if (Math.abs(secs) >= units[i][1]) {
rel = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
.format(Math.round(secs / units[i][1]), units[i][0]);
break;
}
}
el.textContent = abs + ' \u00b7 ' + rel;
el.title = d.toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'long' });
}
document.querySelectorAll('.js-since').forEach(function (el) {
formatSince(el, el.getAttribute('datetime'));
});
// 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'); };
d.bot_initializing ? show('init-banner') : hide('init-banner');
if (d.radio_zombie) {
show('zombie-banner');
if (d.radio_zombie_since) { formatSince(document.getElementById('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) { formatSince(document.getElementById('offline-since-text'), d.radio_offline_since); show('offline-since-span'); }
else { hide('offline-since-span'); }
} else {
hide('offline-banner');
}
// Pages that show radio state (Realtime) read it from here
// rather than polling a second time.
window.dispatchEvent(new CustomEvent('banner-status', { detail: d }));
}
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.io" target="_blank" rel="noopener noreferrer"
style="color: var(--text-muted, #6c757d); text-decoration: none;">MeshCore</a>
</footer>
<!-- Bootstrap JS -->
<script src="{{ url_for('static', filename='vendor/bootstrap/bootstrap.bundle.min.js') }}"></script>
<!-- Socket.IO Client -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- Page-specific libraries (Chart.js, js-sha256) load from the pages that
use them, through the page_libs block. -->
{% block page_libs %}{% endblock %}
<!-- Base JavaScript -->
<script nonce="{{ g.csp_nonce }}">
// Display hint only — server enforces authenticated_admin. Read from a
// data attribute so the inline script stays plain JS for ESLint.
const IS_ADMIN = document.body.dataset.isAdmin === 'true';
// Global connection management
class ModernConnectionManager {
constructor() {
this.socket = null;
this.connected = false;
this.lastActivity = null;
this.pingInterval = null;
// Live Socket.IO stays admin-only when a password is configured
// (packet/message streams can include private mesh traffic).
if (IS_ADMIN) {
this.initializeSocket();
this.startPingInterval();
} else {
this.updateConnectionStatus('Login for live updates', 'disconnected');
}
}
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) {
const d = new Date(this.lastActivity);
const pad = (n) => String(n).padStart(2, '0');
lastUpdateElement.textContent = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
}
}
showNotification(message, type = 'info') {
// Create notification element
const notificationText = document.createElement('span');
notificationText.textContent = String(message ?? '');
window.viewerToast(notificationText, type, 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>