mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-04 03:52:34 +00:00
Screen-reader users skim a page by its headings, and several outlines were broken:
- The dashboard had no h1; it now has a visually hidden "Dashboard" heading ahead of the health strip.
- Feeds and Radio stat cards made the number the heading ("heading level 2, 4") under an h5 label. The label is now the h2 and the number a styled paragraph, and their card sections are h2s instead of h5s straight after the h1. Radio's uppercase group labels become h3s. Feeds' error count toggles its color classes instead of replacing them, which had dropped the readout size.
- Logs went from h1 to h6 for its stream card; it is an h2.
- Config's panel titles are h2s, their group labels h3s, and the database table names (built in JS) h4s.
Visual sizes are unchanged: each heading keeps its old look through Bootstrap's .h5/.h6 classes.
11 of 17 modals had no aria-labelledby, so they announced as an unnamed "dialog". Each now points at its modal title.
axe-core reports no violations on any of 15 pages in either theme.
1965 lines
93 KiB
HTML
1965 lines
93 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Radio Settings - MeshCore Bot{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="mb-0">
|
||
<i class="fas fa-broadcast-tower"></i> Radio Settings
|
||
</h1>
|
||
<div class="d-flex gap-2">
|
||
<button class="btn btn-secondary" id="connectToggleBtn" disabled>
|
||
<span class="spinner-border spinner-border-sm me-1"
|
||
id="connectBtnSpinner" style="display:none" role="status"></span>
|
||
<span id="connectBtnText">Loading...</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Statistics Cards -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h2 class="card-title h5">Total Channels</h2>
|
||
<p id="total-channels" class="h2 mb-2 readout-blue">-</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h2 class="card-title h5">
|
||
Available Slots
|
||
<i class="fas fa-info-circle text-muted ms-1" id="available-slots-info" style="font-size: 0.8em; cursor: help; display: none;" data-bs-toggle="tooltip"></i>
|
||
</h2>
|
||
<p id="available-slots" class="h2 mb-2 readout-teal">-</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h2 class="card-title h5">Channels with Feeds</h2>
|
||
<p id="channels-with-feeds" class="h2 mb-2 readout-violet">-</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h2 class="card-title h5">Empty Channels</h2>
|
||
<p id="empty-channels" class="h2 mb-2 readout-orange">-</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Radio Parameters -->
|
||
<div class="card mb-4" id="radio-params-card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h2 class="h5 mb-0"><i class="fas fa-sliders-h me-2"></i>Radio Parameters</h2>
|
||
<button class="btn btn-sm btn-outline-secondary" id="readRadioParamsBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="readParamsSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-sync me-1" id="readParamsIcon"></i>Read from Device
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="radioParamsAlert" style="display:none"></div>
|
||
<form id="radioParamsForm">
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="radioFreq">Frequency (MHz)</label>
|
||
<input type="number" class="form-control" id="radioFreq" name="freq"
|
||
step="0.001" min="100" max="1700" placeholder="e.g. 915.0">
|
||
<small class="form-text text-muted">100–1700 MHz</small>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="radioBw">Bandwidth (kHz)</label>
|
||
<select class="form-select" id="radioBw" name="bw">
|
||
<option value="">—</option>
|
||
<option value="62.5">62.5</option>
|
||
<option value="125">125</option>
|
||
<option value="250">250</option>
|
||
<option value="500">500</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="radioSf">Spreading Factor</label>
|
||
<select class="form-select" id="radioSf" name="sf">
|
||
<option value="">—</option>
|
||
<option value="5">SF5</option>
|
||
<option value="6">SF6</option>
|
||
<option value="7">SF7</option>
|
||
<option value="8">SF8</option>
|
||
<option value="9">SF9</option>
|
||
<option value="10">SF10</option>
|
||
<option value="11">SF11</option>
|
||
<option value="12">SF12</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="radioCr">Coding Rate</label>
|
||
<select class="form-select" id="radioCr" name="cr">
|
||
<option value="">—</option>
|
||
<option value="5">4/5</option>
|
||
<option value="6">4/6</option>
|
||
<option value="7">4/7</option>
|
||
<option value="8">4/8</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="radioTxPower">TX Power (dBm)</label>
|
||
<input type="number" class="form-control" id="radioTxPower" name="tx_power"
|
||
step="1" min="1" max="30" placeholder="e.g. 22">
|
||
<small class="form-text text-muted" id="maxTxPowerHint"></small>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex gap-2">
|
||
<button type="button" class="btn btn-warning" id="writeRadioParamsBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="writeParamsSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write to Device
|
||
</button>
|
||
<small class="text-muted align-self-center">
|
||
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
|
||
Changing radio parameters affects all nodes on the same frequency.
|
||
</small>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Node Settings -->
|
||
<div class="card mb-4" id="node-settings-card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h2 class="h5 mb-0"><i class="fas fa-microchip me-2"></i>Node Settings</h2>
|
||
<button class="btn btn-sm btn-outline-secondary" id="readNodeSettingsBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="readNodeSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-sync me-1" id="readNodeIcon"></i>Read from Device
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="nodeSettingsAlert" style="display:none"></div>
|
||
|
||
<!-- Response Path Hashing -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Response Path Hashing
|
||
</h3>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodePathHashMode">Path Hash Size</label>
|
||
<select class="form-select" id="nodePathHashMode">
|
||
<option value="">—</option>
|
||
<option value="0">1 byte per hop (mode 0)</option>
|
||
<option value="1">2 bytes per hop (mode 1)</option>
|
||
<option value="2">3 bytes per hop (mode 2)</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-primary" id="savePathHashBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="savePathHashSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Path Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
Hash bytes per hop for outgoing (response) paths — mode 0 is the legacy
|
||
default; larger hashes avoid relay collisions but need firmware 1.14+
|
||
mesh-wide.
|
||
</small>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Default Region Scope (device) -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Default Region Scope
|
||
</h3>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="nodeDefaultScope">Region</label>
|
||
<input type="text" class="form-control" id="nodeDefaultScope"
|
||
maxlength="30" placeholder="blank = unscoped" autocomplete="off">
|
||
<small class="form-text text-muted" id="nodeDefaultScopeKey"></small>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-primary" id="saveDefaultScopeBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveDefaultScopeSpinner"
|
||
style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Default Scope
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
The radio's own default region, stored in firmware and used for any
|
||
send the bot does not scope itself. Max 30 ASCII characters including
|
||
the <code>#</code>, which is added if you omit it. Blank clears it, and
|
||
the radio then floods unscoped.
|
||
</small>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
<i class="fas fa-info-circle me-1"></i>
|
||
Once the bot sends a scoped message it leaves the radio in forced-unscoped
|
||
mode, so this default stops applying until the bot scopes another send.
|
||
If the bot should post under a region, set it in
|
||
<a href="#region-scopes-card">Region Scopes</a> below rather than relying
|
||
on this.
|
||
</small>
|
||
<div class="alert alert-warning py-2 px-3 small mt-2" id="nodeDefaultScopeNote"
|
||
style="display:none"></div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Identity & Adverts -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Identity & Adverts
|
||
</h3>
|
||
<div class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeName">Node Name</label>
|
||
<input type="text" class="form-control" id="nodeName" maxlength="32"
|
||
placeholder="e.g. MyBot"{% if device_name_managed %} disabled{% endif %}>
|
||
{% if device_name_managed %}
|
||
<small class="form-text text-muted">
|
||
Managed by <code>[Bot] bot_name</code> in config.ini —
|
||
reapplied at startup.
|
||
</small>
|
||
{% else %}
|
||
<small class="form-text text-muted">Advertised name (max 32 bytes).</small>
|
||
{% endif %}
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeLat">Latitude</label>
|
||
<input type="number" class="form-control" id="nodeLat" step="0.000001" min="-90" max="90" placeholder="e.g. 47.6">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeLon">Longitude</label>
|
||
<input type="number" class="form-control" id="nodeLon" step="0.000001" min="-180" max="180" placeholder="e.g. -122.3">
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeAdvLocPolicy">Advert Location Policy</label>
|
||
<select class="form-select" id="nodeAdvLocPolicy">
|
||
<option value="">—</option>
|
||
<option value="0">Don't include location</option>
|
||
<option value="1">Include location</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||
<button type="button" class="btn btn-primary" id="saveIdentityBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveIdentitySpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Identity
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary" id="sendAdvertZeroBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="advertZeroSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-bullhorn me-1"></i>Send Advert (Zero-Hop)
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary" id="sendAdvertFloodBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="advertFloodSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-water me-1"></i>Send Flood Advert
|
||
</button>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Mesh Behavior -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Mesh Behavior
|
||
</h3>
|
||
<div class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeNewContactsMode">New Contacts</label>
|
||
<input type="text" class="form-control" id="nodeNewContactsMode" disabled
|
||
value="{% if auto_manage_contacts == 'device' %}Device auto-add (firmware slot policy){% elif auto_manage_contacts == 'bot' %}Bot-managed auto-add{% else %}Manual (!repeater commands){% endif %}">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeMultiAcks">Extra ACKs</label>
|
||
<select class="form-select" id="nodeMultiAcks">
|
||
<option value="">—</option>
|
||
<option value="0">0 (default)</option>
|
||
<option value="1">1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemBase">Base Telemetry</label>
|
||
<select class="form-select" id="nodeTelemBase">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemLoc">Location Telemetry</label>
|
||
<select class="form-select" id="nodeTelemLoc">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemEnv">Env. Telemetry</label>
|
||
<select class="form-select" id="nodeTelemEnv">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
New-contact handling is managed by <code>[Bot] auto_manage_contacts</code>
|
||
in config.ini. Extra ACKs improve delivery at the cost of airtime.
|
||
Telemetry permissions control who may request each reading type.
|
||
</small>
|
||
<div class="mt-3">
|
||
<button type="button" class="btn btn-primary" id="saveMeshBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveMeshSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Mesh Behavior
|
||
</button>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Advanced Tuning -->
|
||
<details>
|
||
<summary class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;cursor:pointer;">
|
||
Advanced Tuning
|
||
</summary>
|
||
<div class="row g-3 mt-1 align-items-end">
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeRxDelay">RX Delay Base (s)</label>
|
||
<input type="number" class="form-control" id="nodeRxDelay" step="0.1" min="0" max="20" placeholder="e.g. 0">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeAirtimeFactor">Airtime Factor</label>
|
||
<input type="number" class="form-control" id="nodeAirtimeFactor" step="0.1" min="0" max="9" placeholder="e.g. 1">
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-outline-warning" id="applyTuningBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="applyTuningSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-wrench me-1"></i>Apply Tuning
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
|
||
Write-only: current values cannot be read back from the device. Both
|
||
fields are required and affect relay timing/duty-cycle behavior.
|
||
</small>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Region Scopes -->
|
||
<div class="card mb-4" id="region-scopes-card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h2 class="h5 mb-0"><i class="fas fa-map-marked-alt me-2"></i>Region Scopes</h2>
|
||
<small class="text-muted" id="regionScopeTarget"></small>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="regionScopeAlert" style="display:none"></div>
|
||
|
||
<p class="text-muted small mb-4">
|
||
A region code confines a channel message to repeaters holding that
|
||
region's key, instead of flooding the whole mesh. These are bot
|
||
settings in <code>config.ini</code>, not device settings, so they
|
||
are not read from or written to the radio — the radio's own
|
||
<a href="#node-settings-card">Default Region Scope</a> is above, and
|
||
only covers sends the bot does not scope itself. See the
|
||
<a href="/region-warnings">Region Warnings</a> page for how much
|
||
unscoped traffic this mesh actually carries.
|
||
</p>
|
||
|
||
<div class="alert alert-warning py-2 px-3 small" id="regionScopeLegacy"
|
||
style="display:none"></div>
|
||
|
||
<!-- Incoming allowlist -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Which scopes the bot replies to
|
||
</h3>
|
||
<div class="form-check mb-2">
|
||
<input class="form-check-input" type="radio" name="regionScopeMode"
|
||
id="regionScopeModeAll" value="all">
|
||
<label class="form-check-label" for="regionScopeModeAll">
|
||
Reply whatever the scope
|
||
<span class="text-muted">— every message the bot hears on a monitored channel.</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-check mb-3">
|
||
<input class="form-check-input" type="radio" name="regionScopeMode"
|
||
id="regionScopeModeList" value="list">
|
||
<label class="form-check-label" for="regionScopeModeList">
|
||
Only these scopes
|
||
<span class="text-muted">— reply mirrors the scope it came in on.</span>
|
||
</label>
|
||
</div>
|
||
<div class="ms-4 ps-2 border-start" id="regionScopeListFields">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label" for="regionScopeList">Region scopes</label>
|
||
<input type="text" class="form-control" id="regionScopeList"
|
||
placeholder="e.g. #west, #east" autocomplete="off">
|
||
<small class="form-text text-muted">
|
||
Comma-separated. The leading <code>#</code> is added if you omit it.
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6 d-flex align-items-center">
|
||
<div class="form-check mt-md-4">
|
||
<input class="form-check-input" type="checkbox" id="regionScopeAllowGlobal">
|
||
<label class="form-check-label" for="regionScopeAllowGlobal">
|
||
Also reply to unscoped (global) messages
|
||
<small class="d-block text-muted">
|
||
Adds <code>*</code>. Without it, a sender with no region set
|
||
gets no reply.
|
||
</small>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Outgoing default -->
|
||
<h3 class="h6 text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Default outgoing scope
|
||
</h3>
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="regionScopeOutgoing">Scope</label>
|
||
<input type="text" class="form-control" id="regionScopeOutgoing"
|
||
placeholder="blank = global flood" autocomplete="off">
|
||
<small class="form-text text-muted">
|
||
Used for sends that have no scope of their own — scheduled
|
||
messages, feeds, webhooks — and for a reply whose incoming
|
||
scope could not be matched. It never overrides a mirrored
|
||
reply scope.
|
||
</small>
|
||
</div>
|
||
<div class="col-md-8">
|
||
<div id="regionScopeChannelOverrides" class="small text-muted"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
|
||
<button type="button" class="btn btn-primary" id="saveRegionScopesBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveRegionScopesSpinner"
|
||
style="display:none" role="status"></span>
|
||
<i class="fas fa-save me-1"></i>Save Region Scopes
|
||
</button>
|
||
<small id="regionScopeStatus" class="text-muted"></small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Channel Management -->
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h2 class="h5 mb-0">Channel Management</h2>
|
||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createChannelModal">
|
||
<i class="fas fa-plus"></i> Create Channel
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-striped table-hover" id="channelsTable">
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th>Index</th>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Feed Count</th>
|
||
<th>Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="channelsTableBody">
|
||
<tr>
|
||
<td colspan="6" class="text-center">Loading...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Channel Modal -->
|
||
<div class="modal fade" id="createChannelModal" tabindex="-1" aria-labelledby="createChannelModalTitle">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="createChannelModalTitle">Create Channel</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="createChannelForm">
|
||
<div class="mb-3">
|
||
<label class="form-label" for="channelName">Channel Name</label>
|
||
<input type="text" class="form-control" id="channelName" name="channel_name" required placeholder="#channelname or channelname">
|
||
<small class="form-text text-muted">
|
||
<strong>Hashtag channel:</strong> Start with # (e.g., #emergency) - key auto-generated<br>
|
||
<strong>Custom channel:</strong> No # prefix - you must provide a channel key
|
||
</small>
|
||
</div>
|
||
<div class="mb-3" id="channelKeyGroup" style="display: none;">
|
||
<label class="form-label" for="channelKey">Channel Key (32 hex characters) <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="channelKey" name="channel_key" placeholder="00000000000000000000000000000000" pattern="[0-9a-fA-F]{32}" maxlength="32">
|
||
<small class="form-text text-muted">Required for custom channels. Must be exactly 32 hexadecimal characters (16 bytes).</small>
|
||
</div>
|
||
<div class="mb-3">
|
||
<div class="alert alert-info">
|
||
<strong>Channel Index:</strong> <span id="autoChannelIndex">-</span>
|
||
<br><small>The lowest available channel index will be automatically selected</small>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-primary" id="saveChannelBtn">Create Channel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Channel Details Modal -->
|
||
<div class="modal fade" id="channelDetailsModal" tabindex="-1" aria-labelledby="channelDetailsModalTitle">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="channelDetailsModalTitle">Channel Details</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body" id="channelDetailsContent">
|
||
Loading...
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script nonce="{{ g.csp_nonce }}">
|
||
class RadioManager {
|
||
constructor() {
|
||
this.channels = [];
|
||
this.maxChannels = 40; // Default, will be updated from API
|
||
this.radioConnected = null;
|
||
this.initialize();
|
||
}
|
||
|
||
async initialize() {
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
await this.loadRadioStatus();
|
||
this.setupEventHandlers();
|
||
this.setupRadioParamsHandlers();
|
||
this.setupNodeSettingsHandlers();
|
||
this.setupRegionScopeHandlers();
|
||
this.readRadioParams({ silent: true });
|
||
this.readFirmwareConfig({ silent: true });
|
||
this.loadRegionScopes();
|
||
|
||
// Auto-refresh every 30 seconds
|
||
setInterval(() => this.loadChannels(), 30000);
|
||
setInterval(() => this.loadStatistics(), 60000);
|
||
setInterval(() => this.loadRadioStatus(), 15000);
|
||
}
|
||
|
||
async loadChannels() {
|
||
try {
|
||
const response = await fetch('/api/channels');
|
||
const data = await response.json();
|
||
// Filter out decode-only channels (they're for packet decoding, not radio management)
|
||
this.channels = (data.channels || []).filter(c => !c.decode_only);
|
||
this.renderChannels();
|
||
this.updateChannelIndexDisplay();
|
||
} catch (error) {
|
||
console.error('Error loading channels:', error);
|
||
this.showError('Failed to load channels: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async loadStatistics() {
|
||
try {
|
||
const response = await fetch('/api/channels/stats');
|
||
const data = await response.json();
|
||
|
||
// Get max_channels from API (defaults to 40 if not provided)
|
||
this.maxChannels = data.max_channels || 40;
|
||
|
||
// Calculate statistics
|
||
const totalChannels = this.channels.length;
|
||
const availableSlots = this.maxChannels - totalChannels;
|
||
const channelsWithFeeds = Object.keys(data.channel_feed_counts || {}).length;
|
||
|
||
// Count channels that are configured but have no feeds
|
||
const feedCounts = data.channel_feed_counts || {};
|
||
let emptyChannels = 0;
|
||
for (const channel of this.channels) {
|
||
const channelName = channel.name || channel.channel_name || `Channel ${channel.channel_idx ?? channel.index}`;
|
||
const feedCount = feedCounts[channelName] || 0;
|
||
if (feedCount === 0) {
|
||
emptyChannels++;
|
||
}
|
||
}
|
||
|
||
document.getElementById('total-channels').textContent = totalChannels;
|
||
|
||
// Update available slots display with info if limit is artificially set
|
||
const availableSlotsElement = document.getElementById('available-slots');
|
||
const availableSlotsInfo = document.getElementById('available-slots-info');
|
||
availableSlotsElement.textContent = availableSlots;
|
||
|
||
// Show info icon and tooltip if max_channels is less than 40
|
||
if (this.maxChannels < 40) {
|
||
availableSlotsInfo.style.display = 'inline';
|
||
const tooltipText = `Limited by max_channels setting (${this.maxChannels} channels). To increase, set [Bot] max_channels in config.ini (max 40).`;
|
||
availableSlotsInfo.setAttribute('title', tooltipText);
|
||
availableSlotsInfo.setAttribute('data-bs-toggle', 'tooltip');
|
||
availableSlotsInfo.setAttribute('data-bs-placement', 'top');
|
||
// Initialize or update Bootstrap tooltip
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(availableSlotsInfo);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
new bootstrap.Tooltip(availableSlotsInfo, {
|
||
title: tooltipText,
|
||
placement: 'top'
|
||
});
|
||
} else {
|
||
availableSlotsInfo.style.display = 'none';
|
||
// Dispose tooltip if it exists
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(availableSlotsInfo);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
}
|
||
|
||
document.getElementById('channels-with-feeds').textContent = channelsWithFeeds;
|
||
document.getElementById('empty-channels').textContent = emptyChannels;
|
||
} catch (error) {
|
||
console.error('Error loading statistics:', error);
|
||
}
|
||
}
|
||
|
||
getLowestAvailableChannelIndex() {
|
||
return MeshCoreChannelOps.getLowestAvailableChannelIndex(this.channels, this.maxChannels);
|
||
}
|
||
|
||
updateChannelIndexDisplay() {
|
||
const display = document.getElementById('autoChannelIndex');
|
||
const lowestIndex = this.getLowestAvailableChannelIndex();
|
||
|
||
if (lowestIndex !== null) {
|
||
display.textContent = `Channel ${lowestIndex}`;
|
||
display.className = 'text-success fw-bold';
|
||
display.title = '';
|
||
} else {
|
||
const limitText = this.maxChannels < 40
|
||
? `all ${this.maxChannels} channels in use (limited by bot.max_channels setting)`
|
||
: `all ${this.maxChannels} channels in use`;
|
||
display.textContent = `No available slots (${limitText})`;
|
||
display.className = 'text-danger fw-bold';
|
||
if (this.maxChannels < 40) {
|
||
display.title = `To increase the limit, set bot.max_channels in config.ini (max 40). Current limit: ${this.maxChannels}`;
|
||
display.style.cursor = 'help';
|
||
} else {
|
||
display.title = '';
|
||
display.style.cursor = 'default';
|
||
}
|
||
}
|
||
}
|
||
|
||
renderChannels() {
|
||
const tbody = document.getElementById('channelsTableBody');
|
||
tbody.replaceChildren();
|
||
|
||
if (this.channels.length === 0) {
|
||
const row = document.createElement('tr');
|
||
const cell = document.createElement('td');
|
||
cell.colSpan = 6;
|
||
cell.className = 'text-center';
|
||
cell.textContent = 'No channels configured';
|
||
row.appendChild(cell);
|
||
tbody.appendChild(row);
|
||
return;
|
||
}
|
||
|
||
// Get feed counts per channel
|
||
fetch('/api/channels/stats').then(r => r.json()).then(stats => {
|
||
const feedCounts = stats.channel_feed_counts || {};
|
||
|
||
tbody.replaceChildren();
|
||
this.channels.forEach(channel => {
|
||
const channelIdx = channel.channel_idx ?? channel.index;
|
||
const channelName = channel.name || channel.channel_name || `Channel ${channelIdx}`;
|
||
const feedCount = feedCounts[channelName] || 0;
|
||
|
||
const row = document.createElement('tr');
|
||
row.appendChild(this.makeTextCell(channelIdx));
|
||
row.appendChild(this.makeTextCell(channelName));
|
||
|
||
const typeCell = document.createElement('td');
|
||
typeCell.appendChild(this.makeBadge(
|
||
channel.type === 'hashtag' ? 'Hashtag' : 'Custom',
|
||
channel.type === 'hashtag' ? 'bg-primary' : 'bg-secondary'
|
||
));
|
||
row.appendChild(typeCell);
|
||
|
||
const feedCell = document.createElement('td');
|
||
if (feedCount > 0) {
|
||
const link = document.createElement('a');
|
||
link.href = `/feeds?channel=${encodeURIComponent(String(channelName))}`;
|
||
link.textContent = String(feedCount);
|
||
feedCell.appendChild(link);
|
||
} else {
|
||
feedCell.textContent = '0';
|
||
}
|
||
row.appendChild(feedCell);
|
||
|
||
const statusCell = document.createElement('td');
|
||
statusCell.appendChild(this.makeBadge('Active', 'bg-success'));
|
||
row.appendChild(statusCell);
|
||
|
||
const actionCell = document.createElement('td');
|
||
const viewButton = this.makeIconButton('btn-info', 'fa-eye', 'View Details');
|
||
viewButton.addEventListener('click', () => this.viewChannel(channelIdx));
|
||
actionCell.appendChild(viewButton);
|
||
|
||
const deleteButton = this.makeIconButton('btn-danger', 'fa-trash', 'Delete');
|
||
deleteButton.dataset.channelIndex = String(channelIdx);
|
||
deleteButton.disabled = feedCount > 0;
|
||
deleteButton.addEventListener('click', () => this.deleteChannel(channelIdx));
|
||
actionCell.appendChild(deleteButton);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
});
|
||
}
|
||
|
||
makeTextCell(value) {
|
||
const cell = document.createElement('td');
|
||
cell.textContent = String(value ?? '');
|
||
return cell;
|
||
}
|
||
|
||
makeBadge(text, colorClass) {
|
||
const badge = document.createElement('span');
|
||
badge.className = `badge ${colorClass}`;
|
||
badge.textContent = String(text ?? '');
|
||
return badge;
|
||
}
|
||
|
||
makeIconButton(colorClass, iconClass, title) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = `btn btn-sm ${colorClass}`;
|
||
button.title = title;
|
||
button.setAttribute('aria-label', title);
|
||
const icon = document.createElement('i');
|
||
icon.className = `fas ${iconClass}`;
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
button.appendChild(icon);
|
||
return button;
|
||
}
|
||
|
||
setLoadingButton(button, label = '') {
|
||
const spinner = document.createElement('span');
|
||
spinner.className = 'spinner-border spinner-border-sm';
|
||
spinner.setAttribute('role', 'status');
|
||
if (label) spinner.classList.add('me-2');
|
||
button.replaceChildren(spinner);
|
||
if (label) button.appendChild(document.createTextNode(label));
|
||
}
|
||
|
||
setupEventHandlers() {
|
||
// Channel name input - update key preview and toggle key input
|
||
const channelNameInput = document.getElementById('channelName');
|
||
if (channelNameInput) {
|
||
channelNameInput.addEventListener('input', (e) => {
|
||
this.handleChannelNameChange(e.target.value).catch(err => {
|
||
console.error('Error in handleChannelNameChange:', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Save channel
|
||
const saveBtn = document.getElementById('saveChannelBtn');
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', () => this.saveChannel());
|
||
}
|
||
|
||
// Radio control buttons
|
||
const connectToggleBtn = document.getElementById('connectToggleBtn');
|
||
if (connectToggleBtn) {
|
||
connectToggleBtn.addEventListener('click', () => this.handleConnectToggle());
|
||
}
|
||
}
|
||
|
||
async loadRadioStatus() {
|
||
try {
|
||
const response = await fetch('/api/radio/status');
|
||
const data = await response.json();
|
||
this.radioConnected = data.connected;
|
||
this.updateConnectButton();
|
||
} catch (error) {
|
||
console.error('Error loading radio status:', error);
|
||
}
|
||
}
|
||
|
||
updateConnectButton() {
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
const txt = document.getElementById('connectBtnText');
|
||
if (!btn || !txt) return;
|
||
|
||
btn.disabled = false;
|
||
if (this.radioConnected === null) {
|
||
btn.className = 'btn btn-secondary';
|
||
txt.textContent = 'Status Unknown';
|
||
} else if (this.radioConnected) {
|
||
// Not a radio-only disconnect: the bot's main loop runs while it is
|
||
// connected, so disconnecting ends the process. Label it honestly.
|
||
btn.className = 'btn btn-danger';
|
||
txt.textContent = 'Stop Bot';
|
||
btn.title = 'Stops the bot process. It restarts only if managed by '
|
||
+ 'systemd or Docker.';
|
||
} else {
|
||
btn.className = 'btn btn-success';
|
||
txt.textContent = 'Connect';
|
||
btn.title = 'Reconnect the radio.';
|
||
}
|
||
}
|
||
|
||
async handleConnectToggle() {
|
||
const action = this.radioConnected ? 'disconnect' : 'connect';
|
||
|
||
if (action === 'disconnect' && !confirm(
|
||
'Stop the bot?\n\n'
|
||
+ 'This shuts the bot down completely, not just the radio link. '
|
||
+ 'It will stop responding on the mesh and this web viewer will '
|
||
+ 'become unavailable.\n\n'
|
||
+ 'It comes back only if something restarts it (systemd, Docker). '
|
||
+ 'If you started it by hand, you will need to start it again the '
|
||
+ 'same way.'
|
||
)) return;
|
||
|
||
const spinner = document.getElementById('connectBtnSpinner');
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
const txt = document.getElementById('connectBtnText');
|
||
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
txt.textContent = action === 'connect' ? 'Connecting...' : 'Stopping...';
|
||
|
||
try {
|
||
const response = await fetch('/api/radio/connect', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
},
|
||
body: JSON.stringify({ action })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.operation_id) {
|
||
await this.pollConnectOperation(data.operation_id, action);
|
||
} else {
|
||
this.showError(data.error || 'Failed to queue operation');
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error: ' + error.message);
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
}
|
||
}
|
||
|
||
async pollConnectOperation(operationId, action, maxWait = 60) {
|
||
const startTime = Date.now();
|
||
const spinner = document.getElementById('connectBtnSpinner');
|
||
const txt = document.getElementById('connectBtnText');
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
|
||
for (let attempts = 0; attempts < maxWait; attempts++) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
txt.textContent = `${action === 'connect' ? 'Connecting' : 'Stopping'}... (${elapsed}s)`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/channel-operations/${operationId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'completed') {
|
||
spinner.style.display = 'none';
|
||
await this.loadRadioStatus();
|
||
this.showSuccess(action === 'connect' ? 'Radio connected successfully' : 'Bot stopping…');
|
||
return;
|
||
} else if (result.status === 'failed' || result.status === 'interrupted') {
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
this.showError(result.error_message || (action === 'connect' ? 'Failed to connect radio' : 'Failed to stop bot'));
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error polling operation:', error);
|
||
}
|
||
}
|
||
|
||
// Timeout
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
await this.loadRadioStatus();
|
||
this.showError('Operation timed out — check radio status.');
|
||
}
|
||
|
||
handleChannelNameChange(channelName) {
|
||
const isHashtag = channelName.trim().startsWith('#');
|
||
const keyGroup = document.getElementById('channelKeyGroup');
|
||
const keyInput = document.getElementById('channelKey');
|
||
|
||
if (isHashtag) {
|
||
// Hashtag channel - hide key input
|
||
keyGroup.style.display = 'none';
|
||
keyInput.removeAttribute('required');
|
||
} else {
|
||
// Custom channel - show key input
|
||
keyGroup.style.display = 'block';
|
||
keyInput.setAttribute('required', 'required');
|
||
}
|
||
}
|
||
|
||
async saveChannel() {
|
||
const form = document.getElementById('createChannelForm');
|
||
const formData = new FormData(form);
|
||
const saveBtn = document.getElementById('saveChannelBtn');
|
||
const originalBtnText = saveBtn.textContent;
|
||
|
||
const channelName = formData.get('channel_name')?.trim();
|
||
const channelKey = formData.get('channel_key')?.trim();
|
||
|
||
if (!channelName) {
|
||
this.showError('Channel name is required');
|
||
return;
|
||
}
|
||
|
||
// Get the lowest available channel index
|
||
const channelIdx = this.getLowestAvailableChannelIndex();
|
||
if (channelIdx === null) {
|
||
const limitMsg = this.maxChannels < 40
|
||
? `No available channel slots. All ${this.maxChannels} channels are in use (limited by bot.max_channels setting). To increase, set bot.max_channels in config.ini (max 40).`
|
||
: `No available channel slots. All ${this.maxChannels} channels are in use.`;
|
||
this.showError(limitMsg);
|
||
return;
|
||
}
|
||
|
||
const isHashtag = channelName.startsWith('#');
|
||
|
||
// Validate custom channel has key
|
||
if (!isHashtag && !channelKey) {
|
||
this.showError('Channel key is required for custom channels (channels without # prefix)');
|
||
return;
|
||
}
|
||
|
||
// Validate key format if provided
|
||
if (channelKey) {
|
||
if (channelKey.length !== 32) {
|
||
this.showError('Channel key must be exactly 32 hexadecimal characters');
|
||
return;
|
||
}
|
||
if (!/^[0-9a-fA-F]{32}$/.test(channelKey)) {
|
||
this.showError('Channel key must contain only hexadecimal characters (0-9, a-f, A-F)');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const data = {
|
||
name: channelName,
|
||
channel_idx: channelIdx
|
||
};
|
||
|
||
// Add key only for custom channels
|
||
if (!isHashtag && channelKey) {
|
||
data.channel_key = channelKey;
|
||
}
|
||
|
||
// Disable button and show loading state
|
||
saveBtn.disabled = true;
|
||
this.setLoadingButton(saveBtn, 'Creating...');
|
||
|
||
try {
|
||
const pr = await MeshCoreChannelOps.postChannel(data);
|
||
const result = pr.result;
|
||
|
||
if (pr.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
this.setLoadingButton(saveBtn, 'Processing...');
|
||
const pollStatus = await MeshCoreChannelOps.pollChannelOperation(result.operation_id, {
|
||
button: saveBtn,
|
||
originalButtonText: originalBtnText,
|
||
onFailed: (msg) => { this.showError(msg); },
|
||
onSlowOperationWarning: () => {
|
||
this.showError('Channel operation is taking longer than expected. The dialog will close automatically when complete.');
|
||
},
|
||
onFinalTimeout: async () => {
|
||
this.showError('Channel operation timed out. Please check the channel list to see if it was created.');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
});
|
||
if (pollStatus === 'completed') {
|
||
this.showSuccess('Channel created successfully');
|
||
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
|
||
form.reset();
|
||
this.handleChannelNameChange('');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = originalBtnText;
|
||
} else {
|
||
this.showSuccess('Channel created successfully');
|
||
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
|
||
form.reset();
|
||
this.handleChannelNameChange('');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = originalBtnText;
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to create channel');
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = originalBtnText;
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error creating channel: ' + error.message);
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = originalBtnText;
|
||
}
|
||
}
|
||
|
||
async viewChannel(channelIdx) {
|
||
try {
|
||
// Get channel feeds
|
||
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
|
||
const feedsData = await feedsResponse.json();
|
||
|
||
const channel = this.channels.find(c => (c.channel_idx ?? c.index) === channelIdx);
|
||
if (!channel) {
|
||
this.showError('Channel not found');
|
||
return;
|
||
}
|
||
|
||
const channelKey = channel.key_hex || '';
|
||
const content = document.getElementById('channelDetailsContent');
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
const infoColumn = document.createElement('div');
|
||
infoColumn.className = 'col-md-6';
|
||
const infoHeading = document.createElement('h6');
|
||
infoHeading.textContent = 'Channel Information';
|
||
infoColumn.appendChild(infoHeading);
|
||
infoColumn.appendChild(this.makeDetailLine('Name:', channel.name || channel.channel_name || ''));
|
||
infoColumn.appendChild(this.makeDetailLine('Index:', channel.channel_idx ?? channel.index));
|
||
infoColumn.appendChild(this.makeDetailLine('Type:', channel.type || 'Unknown'));
|
||
|
||
const keyLine = this.makeDetailLine('Key:', '');
|
||
if (channelKey) {
|
||
const code = document.createElement('code');
|
||
code.style.fontSize = '0.9em';
|
||
code.style.wordBreak = 'break-all';
|
||
code.textContent = String(channelKey);
|
||
keyLine.appendChild(code);
|
||
const copyButton = this.makeIconButton('btn-outline-secondary ms-2', 'fa-copy', 'Copy key');
|
||
copyButton.appendChild(document.createTextNode(' Copy'));
|
||
copyButton.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(String(channelKey));
|
||
this.showSuccess('Key copied to clipboard');
|
||
});
|
||
keyLine.appendChild(copyButton);
|
||
} else {
|
||
const emptyKey = document.createElement('em');
|
||
emptyKey.textContent = 'No key available';
|
||
keyLine.appendChild(emptyKey);
|
||
}
|
||
infoColumn.appendChild(keyLine);
|
||
|
||
const feedsColumn = document.createElement('div');
|
||
feedsColumn.className = 'col-md-6';
|
||
const feedsHeading = document.createElement('h6');
|
||
feedsHeading.textContent = 'Feed Subscriptions';
|
||
feedsColumn.appendChild(feedsHeading);
|
||
if (Array.isArray(feedsData.feeds) && feedsData.feeds.length > 0) {
|
||
const list = document.createElement('ul');
|
||
feedsData.feeds.forEach(feed => {
|
||
const item = document.createElement('li');
|
||
const link = document.createElement('a');
|
||
link.href = `/feeds?id=${encodeURIComponent(String(feed.id ?? ''))}`;
|
||
link.textContent = String(feed.feed_name || feed.feed_url || 'Unnamed feed');
|
||
item.appendChild(link);
|
||
list.appendChild(item);
|
||
});
|
||
feedsColumn.appendChild(list);
|
||
} else {
|
||
const emptyFeeds = document.createElement('p');
|
||
emptyFeeds.textContent = 'No feed subscriptions';
|
||
feedsColumn.appendChild(emptyFeeds);
|
||
}
|
||
row.append(infoColumn, feedsColumn);
|
||
content.replaceChildren(row);
|
||
const modal = new bootstrap.Modal(document.getElementById('channelDetailsModal'));
|
||
modal.show();
|
||
} catch (error) {
|
||
this.showError('Error loading channel details: ' + error.message);
|
||
}
|
||
}
|
||
|
||
makeDetailLine(label, value) {
|
||
const line = document.createElement('p');
|
||
const strong = document.createElement('strong');
|
||
strong.textContent = `${label} `;
|
||
line.append(strong, document.createTextNode(String(value ?? '')));
|
||
return line;
|
||
}
|
||
|
||
async deleteChannel(channelIdx) {
|
||
const channel = this.channels.find(c => (c.channel_idx ?? c.index) === channelIdx);
|
||
if (!channel) {
|
||
this.showError('Channel not found');
|
||
return;
|
||
}
|
||
|
||
// Check if channel has feeds
|
||
try {
|
||
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
|
||
const feedsData = await feedsResponse.json();
|
||
|
||
if (feedsData.feeds && feedsData.feeds.length > 0) {
|
||
if (!confirm(`This channel has ${feedsData.feeds.length} active feed subscription(s). Are you sure you want to delete it?`)) {
|
||
return;
|
||
}
|
||
} else {
|
||
if (!confirm('Are you sure you want to delete this channel?')) {
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Continue with deletion even if feed check fails
|
||
}
|
||
|
||
// Find the delete button and show loading state
|
||
const deleteBtn = Array.from(document.querySelectorAll('button[data-channel-index]'))
|
||
.find(button => button.dataset.channelIndex === String(channelIdx)) || null;
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = true;
|
||
this.setLoadingButton(deleteBtn);
|
||
|
||
try {
|
||
const response = await fetch(`/api/channels/${channelIdx}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
// Operation is queued, poll for status
|
||
await this.pollDeleteOperationStatus(result.operation_id, deleteBtn);
|
||
} else {
|
||
// Immediate success
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to delete channel');
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error deleting channel: ' + error.message);
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback if button not found
|
||
try {
|
||
const response = await fetch(`/api/channels/${channelIdx}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
await this.pollDeleteOperationStatus(result.operation_id);
|
||
} else {
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to delete channel');
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error deleting channel: ' + error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
restoreDeleteButton(button) {
|
||
const icon = document.createElement('i');
|
||
icon.className = 'fas fa-trash';
|
||
button.replaceChildren(icon);
|
||
}
|
||
|
||
async pollDeleteOperationStatus(operationId, button = null, maxWait = 60) {
|
||
const startTime = Date.now();
|
||
const checkInterval = 1000; // Check every second
|
||
let attempts = 0;
|
||
const maxAttempts = Math.floor(maxWait * 1000 / checkInterval);
|
||
|
||
while (attempts < maxAttempts) {
|
||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||
attempts++;
|
||
|
||
try {
|
||
const response = await fetch(`/api/channel-operations/${operationId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'completed') {
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
if (button) {
|
||
button.disabled = false;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
return;
|
||
} else if (result.status === 'failed' || result.status === 'interrupted') {
|
||
this.showError(result.error_message || 'Channel deletion failed');
|
||
if (button) {
|
||
button.disabled = false;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
return;
|
||
}
|
||
// If still pending, continue polling
|
||
|
||
} catch (error) {
|
||
console.error('Error polling operation status:', error);
|
||
// Continue polling on error
|
||
}
|
||
}
|
||
|
||
// Timeout
|
||
this.showError('Channel deletion is taking longer than expected. Please check the channel list to see if it was deleted.');
|
||
if (button) {
|
||
button.disabled = false;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
// Still refresh the channel list in case it was deleted
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
|
||
setupRadioParamsHandlers() {
|
||
document.getElementById('readRadioParamsBtn').addEventListener('click', () => this.readRadioParams());
|
||
document.getElementById('writeRadioParamsBtn').addEventListener('click', () => this.writeRadioParams());
|
||
}
|
||
|
||
async readRadioParams({ silent = false } = {}) {
|
||
const btn = document.getElementById('readRadioParamsBtn');
|
||
const spinner = document.getElementById('readParamsSpinner');
|
||
const icon = document.getElementById('readParamsIcon');
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
icon.style.display = 'none';
|
||
if (!silent) this.setParamsAlert('', '');
|
||
try {
|
||
const resp = await fetch('/api/radio/params');
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.operation_id) {
|
||
if (!silent) this.setParamsAlert('danger', data.error || 'Failed to queue read');
|
||
return;
|
||
}
|
||
const result = await this.pollRadioParamsOp(data.operation_id);
|
||
if (result && result.status === 'completed' && result.result_data) {
|
||
this.fillRadioParamsForm(result.result_data);
|
||
this.fillNodeSettingsForm(result.result_data);
|
||
if (!silent) this.setParamsAlert('success', 'Radio parameters read successfully.');
|
||
} else {
|
||
const msg = result?.error_message || 'Read failed';
|
||
if (silent && msg.toLowerCase().includes('not connected')) return;
|
||
this.setParamsAlert('danger', msg);
|
||
}
|
||
} catch (e) {
|
||
if (!silent) this.setParamsAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
icon.style.display = 'inline';
|
||
}
|
||
}
|
||
|
||
async writeRadioParams() {
|
||
const freq = document.getElementById('radioFreq').value.trim();
|
||
const bw = document.getElementById('radioBw').value;
|
||
const sf = document.getElementById('radioSf').value;
|
||
const cr = document.getElementById('radioCr').value;
|
||
const txPower = document.getElementById('radioTxPower').value.trim();
|
||
|
||
const payload = {};
|
||
if (freq || bw || sf || cr) {
|
||
if (!freq || !bw || !sf || !cr) {
|
||
this.setParamsAlert('danger', 'Frequency, bandwidth, spreading factor, and coding rate must all be provided together.');
|
||
return;
|
||
}
|
||
payload.freq = parseFloat(freq);
|
||
payload.bw = parseFloat(bw);
|
||
payload.sf = parseInt(sf);
|
||
payload.cr = parseInt(cr);
|
||
}
|
||
if (txPower) {
|
||
payload.tx_power = parseInt(txPower);
|
||
}
|
||
if (!Object.keys(payload).length) {
|
||
this.setParamsAlert('danger', 'No parameters to write.');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Write these radio parameters to the device? This will affect communication with all other nodes on the same frequency.')) {
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('writeRadioParamsBtn');
|
||
const spinner = document.getElementById('writeParamsSpinner');
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
this.setParamsAlert('', '');
|
||
try {
|
||
const resp = await fetch('/api/radio/params', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.operation_id) {
|
||
this.setParamsAlert('danger', data.error || 'Failed to queue write');
|
||
return;
|
||
}
|
||
const result = await this.pollRadioParamsOp(data.operation_id);
|
||
if (result && result.status === 'completed') {
|
||
this.setParamsAlert('success', 'Radio parameters written successfully.');
|
||
} else {
|
||
this.setParamsAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
} catch (e) {
|
||
this.setParamsAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async pollRadioParamsOp(opId, maxAttempts = 30) {
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
try {
|
||
const resp = await fetch(`/api/channel-operations/${opId}`);
|
||
const data = await resp.json();
|
||
if (data.status === 'completed' || data.status === 'failed' || data.status === 'interrupted') return data;
|
||
} catch (_) {}
|
||
}
|
||
return { status: 'failed', error_message: 'Timed out waiting for device response' };
|
||
}
|
||
|
||
fillRadioParamsForm(params) {
|
||
if (params.freq != null) document.getElementById('radioFreq').value = params.freq;
|
||
if (params.bw != null) {
|
||
const sel = document.getElementById('radioBw');
|
||
sel.value = String(params.bw);
|
||
if (!sel.value) sel.value = '';
|
||
}
|
||
if (params.sf != null) document.getElementById('radioSf').value = String(params.sf);
|
||
if (params.cr != null) document.getElementById('radioCr').value = String(params.cr);
|
||
if (params.tx_power != null) {
|
||
document.getElementById('radioTxPower').value = params.tx_power;
|
||
if (params.max_tx_power != null) {
|
||
document.getElementById('radioTxPower').max = params.max_tx_power;
|
||
document.getElementById('maxTxPowerHint').textContent = `Max: ${params.max_tx_power} dBm`;
|
||
}
|
||
}
|
||
}
|
||
|
||
setParamsAlert(type, message) {
|
||
const el = document.getElementById('radioParamsAlert');
|
||
if (!type || !message) { el.style.display = 'none'; return; }
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = message;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
setupNodeSettingsHandlers() {
|
||
document.getElementById('readNodeSettingsBtn').addEventListener('click', () => {
|
||
this.readRadioParams();
|
||
this.readFirmwareConfig();
|
||
});
|
||
document.getElementById('savePathHashBtn').addEventListener('click', () => this.savePathHash());
|
||
document.getElementById('saveDefaultScopeBtn')
|
||
.addEventListener('click', () => this.saveDefaultScope());
|
||
document.getElementById('saveIdentityBtn').addEventListener('click', () => this.saveIdentity());
|
||
document.getElementById('saveMeshBtn').addEventListener('click', () => this.saveMesh());
|
||
document.getElementById('applyTuningBtn').addEventListener('click', () => this.applyTuning());
|
||
document.getElementById('sendAdvertZeroBtn').addEventListener('click', () => this.sendAdvert(false));
|
||
document.getElementById('sendAdvertFloodBtn').addEventListener('click', () => this.sendAdvert(true));
|
||
}
|
||
|
||
setNodeAlert(type, message) {
|
||
const el = document.getElementById('nodeSettingsAlert');
|
||
if (!type || !message) { el.style.display = 'none'; return; }
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = message;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
// Queue an operation via POST (or GET when payload is null), poll it, and
|
||
// return the completed/failed operation record.
|
||
async queueAndPoll(url, payload) {
|
||
const options = payload === null
|
||
? undefined
|
||
: {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
body: JSON.stringify(payload)
|
||
};
|
||
const resp = await fetch(url, options);
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.operation_id) {
|
||
return { status: 'failed', error_message: data.error || 'Failed to queue operation' };
|
||
}
|
||
return await this.pollRadioParamsOp(data.operation_id);
|
||
}
|
||
|
||
// Run an async write action with button/spinner state and shared alert handling.
|
||
async runNodeAction(btnId, spinnerId, action) {
|
||
const btn = document.getElementById(btnId);
|
||
const spinner = document.getElementById(spinnerId);
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
this.setNodeAlert('', '');
|
||
try {
|
||
await action();
|
||
} catch (e) {
|
||
this.setNodeAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async readFirmwareConfig({ silent = false } = {}) {
|
||
try {
|
||
const result = await this.queueAndPoll('/api/radio/firmware/config/read', {});
|
||
if (result && result.status === 'completed' && result.result_data) {
|
||
this.fillFirmwareConfigForm(result.result_data);
|
||
if (!silent) this.setNodeAlert('success', 'Node settings read successfully.');
|
||
} else {
|
||
const msg = result?.error_message || 'Firmware config read failed';
|
||
if (silent && msg.toLowerCase().includes('not connected')) return;
|
||
this.setNodeAlert('danger', msg);
|
||
}
|
||
} catch (e) {
|
||
if (!silent) this.setNodeAlert('danger', 'Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
fillNodeSettingsForm(params) {
|
||
if (params.name != null) document.getElementById('nodeName').value = params.name;
|
||
if (params.adv_lat != null) document.getElementById('nodeLat').value = params.adv_lat;
|
||
if (params.adv_lon != null) document.getElementById('nodeLon').value = params.adv_lon;
|
||
if (params.adv_loc_policy != null) document.getElementById('nodeAdvLocPolicy').value = String(params.adv_loc_policy);
|
||
if (params.multi_acks != null) document.getElementById('nodeMultiAcks').value = String(params.multi_acks);
|
||
if (params.telemetry_mode_base != null) document.getElementById('nodeTelemBase').value = String(params.telemetry_mode_base);
|
||
if (params.telemetry_mode_loc != null) document.getElementById('nodeTelemLoc').value = String(params.telemetry_mode_loc);
|
||
if (params.telemetry_mode_env != null) document.getElementById('nodeTelemEnv').value = String(params.telemetry_mode_env);
|
||
}
|
||
|
||
fillFirmwareConfigForm(data) {
|
||
if (data.path_hash_mode != null && data.path_hash_mode >= 0) {
|
||
document.getElementById('nodePathHashMode').value = String(data.path_hash_mode);
|
||
}
|
||
this.fillDefaultScope(data);
|
||
}
|
||
|
||
fillDefaultScope(data) {
|
||
const field = document.getElementById('nodeDefaultScope');
|
||
const keyNote = document.getElementById('nodeDefaultScopeKey');
|
||
const note = document.getElementById('nodeDefaultScopeNote');
|
||
keyNote.textContent = '';
|
||
note.style.display = 'none';
|
||
|
||
// Absent keys mean the radio never answered — older firmware has no
|
||
// CMD_GET_DEFAULT_FLOOD_SCOPE. Say so rather than showing an empty box
|
||
// that reads as "no scope set".
|
||
if (!data.default_scope_supported) {
|
||
field.value = '';
|
||
field.disabled = true;
|
||
document.getElementById('saveDefaultScopeBtn').disabled = true;
|
||
note.textContent = 'This radio did not report a default scope. The firmware '
|
||
+ 'is likely older than the setting; nothing was changed.';
|
||
note.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
field.disabled = false;
|
||
document.getElementById('saveDefaultScopeBtn').disabled = false;
|
||
const name = data.default_scope_name || '';
|
||
field.value = name;
|
||
if (!name) return;
|
||
|
||
keyNote.textContent = `Key ${(data.default_scope_key || '').slice(0, 8)}…`;
|
||
// The name is a label the app wrote; the key is what the radio routes
|
||
// by. They only disagree if something wrote them separately, and then
|
||
// the name on screen is not the region this radio is actually in.
|
||
if (data.default_scope_key && data.default_scope_key_matches === false) {
|
||
note.textContent = `The stored key does not match ${name}. The radio routes by `
|
||
+ 'the key, so the name shown is only a label. Writing the scope again '
|
||
+ 'puts the two back in step.';
|
||
note.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async saveDefaultScope() {
|
||
const raw = document.getElementById('nodeDefaultScope').value.trim();
|
||
if (!raw && !confirm(
|
||
'Clear the radio\'s default region scope?\n\n'
|
||
+ 'Sends the bot does not scope itself will then flood the whole mesh '
|
||
+ 'instead of staying inside a region.'
|
||
)) return;
|
||
|
||
await this.runNodeAction('saveDefaultScopeBtn', 'saveDefaultScopeSpinner', async () => {
|
||
const result = await this.queueAndPoll(
|
||
'/api/radio/firmware/config/write', { default_flood_scope: raw });
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', raw
|
||
? `Default scope set to ${raw}.`
|
||
: 'Default scope cleared — the radio floods unscoped.');
|
||
// Read it back: the radio is the authority on what it stored.
|
||
await this.readFirmwareConfig({ silent: true });
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async savePathHash() {
|
||
const mode = document.getElementById('nodePathHashMode').value;
|
||
if (mode === '') {
|
||
this.setNodeAlert('danger', 'No path settings to write.');
|
||
return;
|
||
}
|
||
const payload = { path_hash_mode: parseInt(mode) };
|
||
if (!confirm('Change the path hash size? Responses may fail to route until other nodes on the mesh run firmware that understands this hash size.')) {
|
||
return;
|
||
}
|
||
await this.runNodeAction('savePathHashBtn', 'savePathHashSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/firmware/config/write', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Path settings written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async saveIdentity() {
|
||
const nameInput = document.getElementById('nodeName');
|
||
// A disabled name field is config-managed ([Bot] bot_name); never write it.
|
||
const name = nameInput.disabled ? '' : nameInput.value.trim();
|
||
const lat = document.getElementById('nodeLat').value.trim();
|
||
const lon = document.getElementById('nodeLon').value.trim();
|
||
const policy = document.getElementById('nodeAdvLocPolicy').value;
|
||
|
||
if ((lat === '') !== (lon === '')) {
|
||
this.setNodeAlert('danger', 'Latitude and longitude must be provided together.');
|
||
return;
|
||
}
|
||
const payload = {};
|
||
if (name) payload.name = name;
|
||
if (lat !== '') {
|
||
payload.lat = parseFloat(lat);
|
||
payload.lon = parseFloat(lon);
|
||
}
|
||
if (policy !== '') payload.adv_loc_policy = parseInt(policy);
|
||
if (!Object.keys(payload).length) {
|
||
this.setNodeAlert('danger', 'No identity settings to write.');
|
||
return;
|
||
}
|
||
await this.runNodeAction('saveIdentityBtn', 'saveIdentitySpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Identity written successfully. Send an advert to announce the changes.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async saveMesh() {
|
||
const multiAcks = document.getElementById('nodeMultiAcks').value;
|
||
const telemBase = document.getElementById('nodeTelemBase').value;
|
||
const telemLoc = document.getElementById('nodeTelemLoc').value;
|
||
const telemEnv = document.getElementById('nodeTelemEnv').value;
|
||
|
||
const payload = {};
|
||
if (multiAcks !== '') payload.multi_acks = parseInt(multiAcks);
|
||
if (telemBase !== '') payload.telemetry_mode_base = parseInt(telemBase);
|
||
if (telemLoc !== '') payload.telemetry_mode_loc = parseInt(telemLoc);
|
||
if (telemEnv !== '') payload.telemetry_mode_env = parseInt(telemEnv);
|
||
if (!Object.keys(payload).length) {
|
||
this.setNodeAlert('danger', 'No mesh behavior settings to write.');
|
||
return;
|
||
}
|
||
await this.runNodeAction('saveMeshBtn', 'saveMeshSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Mesh behavior written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async applyTuning() {
|
||
const rxDelay = document.getElementById('nodeRxDelay').value.trim();
|
||
const airtimeFactor = document.getElementById('nodeAirtimeFactor').value.trim();
|
||
if (rxDelay === '' || airtimeFactor === '') {
|
||
this.setNodeAlert('danger', 'RX delay and airtime factor must both be provided.');
|
||
return;
|
||
}
|
||
if (!confirm('Apply tuning parameters? These affect relay timing and airtime budgeting, and current values cannot be read back from the device.')) {
|
||
return;
|
||
}
|
||
await this.runNodeAction('applyTuningBtn', 'applyTuningSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', {
|
||
rx_delay: parseFloat(rxDelay),
|
||
airtime_factor: parseFloat(airtimeFactor)
|
||
});
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Tuning parameters written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async sendAdvert(flood) {
|
||
if (flood && !confirm('Send a flood advert? It will be repeated across the whole mesh — use sparingly.')) {
|
||
return;
|
||
}
|
||
const btnId = flood ? 'sendAdvertFloodBtn' : 'sendAdvertZeroBtn';
|
||
const spinnerId = flood ? 'advertFloodSpinner' : 'advertZeroSpinner';
|
||
await this.runNodeAction(btnId, spinnerId, async () => {
|
||
const result = await this.queueAndPoll('/api/radio/advert', { flood });
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', flood ? 'Flood advert sent.' : 'Zero-hop advert sent.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Advert failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// ── Region scopes ([Channels] flood_scopes) ─────────────────────────────
|
||
|
||
setupRegionScopeHandlers() {
|
||
const sync = () => this.syncRegionScopeMode();
|
||
document.getElementById('regionScopeModeAll').addEventListener('change', sync);
|
||
document.getElementById('regionScopeModeList').addEventListener('change', sync);
|
||
document.getElementById('saveRegionScopesBtn')
|
||
.addEventListener('click', () => this.saveRegionScopes());
|
||
}
|
||
|
||
// The allowlist inputs are meaningless in "reply whatever the scope" mode,
|
||
// so disable rather than hide them: a hidden list the operator forgot they
|
||
// filled in is the version of this that loses their scopes on the next save.
|
||
syncRegionScopeMode() {
|
||
const listMode = document.getElementById('regionScopeModeList').checked;
|
||
document.getElementById('regionScopeList').disabled = !listMode;
|
||
document.getElementById('regionScopeAllowGlobal').disabled = !listMode;
|
||
document.getElementById('regionScopeListFields').style.opacity = listMode ? '1' : '.55';
|
||
}
|
||
|
||
async loadRegionScopes() {
|
||
try {
|
||
const resp = await fetch('/api/region-scopes', {
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
this.setRegionScopeAlert('danger', data.error || 'Could not read region scopes');
|
||
return;
|
||
}
|
||
this.fillRegionScopeForm(data);
|
||
this.setRegionScopeAlert('', '');
|
||
} catch (e) {
|
||
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
fillRegionScopeForm(data) {
|
||
const scopes = Array.isArray(data.scopes) ? data.scopes : [];
|
||
document.getElementById('regionScopeList').value = scopes.join(', ');
|
||
document.getElementById('regionScopeAllowGlobal').checked = !!data.allow_global;
|
||
document.getElementById('regionScopeOutgoing').value = data.outgoing_override || '';
|
||
const listMode = !!data.allowlist_active;
|
||
document.getElementById('regionScopeModeList').checked = listMode;
|
||
document.getElementById('regionScopeModeAll').checked = !listMode;
|
||
this.syncRegionScopeMode();
|
||
|
||
const target = document.getElementById('regionScopeTarget');
|
||
target.textContent = data.target ? `[Channels] in ${data.target}` : '';
|
||
|
||
const legacy = document.getElementById('regionScopeLegacy');
|
||
if (data.legacy_section) {
|
||
// [Channels] is empty, so the bot fell back to the old location.
|
||
// Saving an allowlist here takes over; saving "reply whatever the
|
||
// scope" leaves [Channels] empty and hands it straight back.
|
||
legacy.textContent =
|
||
`[Channels] has no flood_scopes, so the bot is using the one under `
|
||
+ `[${data.legacy_section}]. Saving a list here writes [Channels], which `
|
||
+ `the bot reads first. Saving "reply whatever the scope" leaves [Channels] `
|
||
+ `empty, and the [${data.legacy_section}] entry takes over again — remove `
|
||
+ 'it from config.ini by hand to turn the allowlist off for good.';
|
||
legacy.style.display = 'block';
|
||
} else {
|
||
legacy.style.display = 'none';
|
||
}
|
||
|
||
this.renderChannelScopeOverrides(data.channel_overrides || []);
|
||
}
|
||
|
||
renderChannelScopeOverrides(overrides) {
|
||
const host = document.getElementById('regionScopeChannelOverrides');
|
||
host.replaceChildren();
|
||
if (!overrides.length) return;
|
||
|
||
const heading = document.createElement('div');
|
||
heading.className = 'fw-semibold mb-1';
|
||
heading.textContent = 'Per-channel overrides (edit in config.ini)';
|
||
host.appendChild(heading);
|
||
|
||
const list = document.createElement('ul');
|
||
list.className = 'list-unstyled mb-0';
|
||
overrides.forEach(entry => {
|
||
const item = document.createElement('li');
|
||
const name = document.createElement('code');
|
||
name.textContent = entry.channel;
|
||
item.appendChild(name);
|
||
item.appendChild(document.createTextNode(' → '));
|
||
const scope = document.createElement('code');
|
||
scope.textContent = entry.scope || 'global flood';
|
||
item.appendChild(scope);
|
||
list.appendChild(item);
|
||
});
|
||
host.appendChild(list);
|
||
|
||
const note = document.createElement('div');
|
||
note.className = 'mt-1';
|
||
note.textContent = 'These win over the default outgoing scope on their channel.';
|
||
host.appendChild(note);
|
||
}
|
||
|
||
async saveRegionScopes() {
|
||
const listMode = document.getElementById('regionScopeModeList').checked;
|
||
const payload = {
|
||
allowlist_enabled: listMode,
|
||
scopes: listMode ? document.getElementById('regionScopeList').value : '',
|
||
allow_global: listMode && document.getElementById('regionScopeAllowGlobal').checked,
|
||
outgoing_override: document.getElementById('regionScopeOutgoing').value,
|
||
};
|
||
|
||
if (listMode && !payload.allow_global) {
|
||
const ok = confirm(
|
||
'Only reply to the listed scopes?\n\n'
|
||
+ 'Anyone whose client has no region set floods unscoped, and the bot '
|
||
+ 'will not answer them at all. Tick "also reply to unscoped messages" '
|
||
+ 'to keep answering those.'
|
||
);
|
||
if (!ok) return;
|
||
}
|
||
|
||
const btn = document.getElementById('saveRegionScopesBtn');
|
||
const spinner = document.getElementById('saveRegionScopesSpinner');
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
this.setRegionScopeAlert('', '');
|
||
this.setRegionScopeStatus('Saving…', 'text-muted');
|
||
try {
|
||
const resp = await fetch('/api/region-scopes', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
this.setRegionScopeStatus('', '');
|
||
this.setRegionScopeAlert('danger', data.error || 'Save failed');
|
||
return;
|
||
}
|
||
// Show what was actually written, including the canonical form of
|
||
// anything typed loosely (west -> #west, * folded into the checkbox).
|
||
this.fillRegionScopeForm(data.settings || {});
|
||
this.setRegionScopeStatus('Saved. Waiting for the bot to reload…', 'text-muted');
|
||
const applied = await this.awaitConfigReload(data.reload_operation_id);
|
||
this.setRegionScopeStatus(applied.text, applied.cls);
|
||
} catch (e) {
|
||
this.setRegionScopeStatus('', '');
|
||
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// A queued reload row only means the request was written down. Poll it so
|
||
// the page reports what the bot did, and says plainly when nothing picked
|
||
// it up (a stopped bot, or a reload the bot refused).
|
||
async awaitConfigReload(operationId) {
|
||
if (!operationId) {
|
||
return {
|
||
text: 'Saved to config.ini. It applies the next time the bot reloads.',
|
||
cls: 'text-warning'
|
||
};
|
||
}
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
try {
|
||
const resp = await fetch(`/api/channel-operations/${operationId}`);
|
||
const data = await resp.json();
|
||
if (data.status === 'completed') {
|
||
return { text: 'Saved and applied — the bot is using it now.', cls: 'text-success' };
|
||
}
|
||
if (data.status === 'failed' || data.status === 'interrupted') {
|
||
// reload_config's messages already end in a full stop.
|
||
const reason = String(data.error_message || 'reason not reported')
|
||
.trim().replace(/\.+$/, '');
|
||
return {
|
||
text: `Saved to config.ini, but the reload failed: ${reason}. `
|
||
+ 'Restart the bot to apply it.',
|
||
cls: 'text-danger'
|
||
};
|
||
}
|
||
} catch { /* keep polling; the answer is the same either way */ }
|
||
}
|
||
return {
|
||
text: 'Saved to config.ini. The bot has not picked it up — if it is not '
|
||
+ 'running, this applies at startup.',
|
||
cls: 'text-warning'
|
||
};
|
||
}
|
||
|
||
setRegionScopeAlert(type, message) {
|
||
const el = document.getElementById('regionScopeAlert');
|
||
if (!type || !message) { el.style.display = 'none'; return; }
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = message;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
setRegionScopeStatus(text, cls) {
|
||
const el = document.getElementById('regionScopeStatus');
|
||
el.className = cls || 'text-muted';
|
||
el.textContent = text || '';
|
||
}
|
||
|
||
showError(message) {
|
||
const messageNode = document.createElement('span');
|
||
messageNode.textContent = String(message ?? '');
|
||
window.viewerToast(messageNode, 'danger', 5000);
|
||
}
|
||
|
||
showSuccess(message) {
|
||
const messageNode = document.createElement('span');
|
||
messageNode.textContent = String(message ?? '');
|
||
window.viewerToast(messageNode, 'success', 3000);
|
||
}
|
||
}
|
||
|
||
// Initialize radio manager when page loads
|
||
let radioManager;
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
radioManager = new RadioManager();
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
/* Dark mode table styling with better contrast - matching contacts page */
|
||
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
|
||
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
|
||
}
|
||
|
||
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
|
||
background-color: #2d2d2d; /* Darker gray (card-bg) */
|
||
}
|
||
|
||
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
|
||
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
|
||
}
|
||
|
||
[data-theme="dark"] .table-dark {
|
||
background-color: var(--bg-tertiary);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
[data-theme="dark"] .table-dark th {
|
||
background-color: var(--bg-tertiary);
|
||
border-color: var(--border-color);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
[data-theme="dark"] .table td {
|
||
border-color: var(--border-color);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
/* Table cells take the light text color in dark mode; children with their
|
||
own color (badges, buttons, status text) keep it. */
|
||
[data-theme="dark"] .table td {
|
||
color: var(--text-color) !important;
|
||
}
|
||
|
||
/* Ensure small text is also light */
|
||
[data-theme="dark"] .table td small {
|
||
color: var(--text-muted) !important;
|
||
}
|
||
</style>
|
||
{% endblock %}
|