Files
meshcore-bot/modules/web_viewer/templates/radio.html
T
agessaman 4d49c6b0b8 feat(web-viewer): Node Settings card on the Radio page
Adds device-level companion settings to /radio, headlined by the response
path hash size (mode 0-2 = 1-3 bytes per hop) wired to the existing
firmware config endpoints. New sections: Identity & Adverts (name, advert
lat/lon, location policy, zero-hop/flood advert buttons), Mesh Behavior
(extra ACKs, telemetry permissions), and write-only Advanced Tuning
(RX delay base, airtime factor, sent x1000 per the wire format).

Backend: GET /api/radio/params now returns the full SELF_INFO node fields;
POST accepts the new fields with validation; new POST /api/radio/advert;
scheduler ops handle the writes (other-params group as one read-modify-write
frame so partial updates never clobber device state).

Config-managed settings are not writable from the panel: new-contact mode
is owned by [Bot] auto_manage_contacts and shown read-only; the node name
is locked while bot_name + auto_update_device_name manage it. loop.detect
is removed from the firmware endpoints entirely - it is repeater/room-server
CLI config, and companion custom vars map to sensor settings.

Also: dark-mode styling for disabled form fields (base.html), TASK-01 guard
test updated for the deliberate reintroduction, 23 endpoint/template tests.
2026-07-12 11:57:28 -07:00

1436 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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">
<h5 class="card-title">Total Channels</h5>
<h2 id="total-channels">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">
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>
</h5>
<h2 id="available-slots">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Channels with Feeds</h5>
<h2 id="channels-with-feeds">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Empty Channels</h5>
<h2 id="empty-channels">-</h2>
</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">
<h5 class="mb-0"><i class="fas fa-sliders-h me-2"></i>Radio Parameters</h5>
<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">1001700 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">
<h5 class="mb-0"><i class="fas fa-microchip me-2"></i>Node Settings</h5>
<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 -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Response Path Hashing
</h6>
<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-warning" 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">
<!-- Identity & Adverts -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Identity &amp; Adverts
</h6>
<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-warning" 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 -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Mesh Behavior
</h6>
<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-warning" 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>
<!-- Channel Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Channel Management</h5>
<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">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Channel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createChannelForm">
<div class="mb-3">
<label class="form-label">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">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">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Channel Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></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>
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.readRadioParams({ silent: true });
this.readFirmwareConfig({ silent: true });
// 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');
if (this.channels.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No channels configured</td></tr>';
return;
}
// Get feed counts per channel
fetch('/api/channels/stats').then(r => r.json()).then(stats => {
const feedCounts = stats.channel_feed_counts || {};
tbody.innerHTML = this.channels.map(channel => {
const channelName = channel.name || channel.channel_name || `Channel ${channel.channel_idx || channel.index}`;
const feedCount = feedCounts[channelName] || 0;
const typeBadge = channel.type === 'hashtag'
? '<span class="badge bg-primary">Hashtag</span>'
: '<span class="badge bg-secondary">Custom</span>';
return `
<tr>
<td>${channel.channel_idx || channel.index}</td>
<td>${channelName}</td>
<td>${typeBadge}</td>
<td>${feedCount > 0 ? `<a href="/feeds?channel=${encodeURIComponent(channelName)}">${feedCount}</a>` : '0'}</td>
<td><span class="badge bg-success">Active</span></td>
<td>
<button class="btn btn-sm btn-info" onclick="radioManager.viewChannel(${channel.channel_idx || channel.index})" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="radioManager.deleteChannel(${channel.channel_idx || channel.index})" title="Delete" ${feedCount > 0 ? 'disabled' : ''}>
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
});
}
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) {
btn.className = 'btn btn-danger';
txt.textContent = 'Disconnect';
} else {
btn.className = 'btn btn-success';
txt.textContent = 'Connect';
}
}
async handleConnectToggle() {
const action = this.radioConnected ? 'disconnect' : 'connect';
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...' : 'Disconnecting...';
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' : 'Disconnecting'}... (${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(`Radio ${action}ed successfully`);
return;
} else if (result.status === 'failed') {
spinner.style.display = 'none';
btn.disabled = false;
this.updateConnectButton();
this.showError(result.error_message || `Failed to ${action} radio`);
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.innerHTML;
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;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
try {
const pr = await MeshCoreChannelOps.postChannel(data);
const result = pr.result;
if (pr.ok) {
if (result.pending && result.operation_id) {
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>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.innerHTML = 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.innerHTML = originalBtnText;
}
} else {
this.showError(result.error || 'Failed to create channel');
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
} catch (error) {
this.showError('Error creating channel: ' + error.message);
saveBtn.disabled = false;
saveBtn.innerHTML = 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 keyDisplay = channelKey
? `<code style="font-size: 0.9em; word-break: break-all;">${channelKey}</code> <button class="btn btn-sm btn-outline-secondary ms-2" onclick="navigator.clipboard.writeText('${channelKey}'); radioManager.showSuccess('Key copied to clipboard');"><i class="fas fa-copy"></i> Copy</button>`
: '<em>No key available</em>';
const content = `
<div class="row">
<div class="col-md-6">
<h6>Channel Information</h6>
<p><strong>Name:</strong> ${channel.name || channel.channel_name}</p>
<p><strong>Index:</strong> ${channel.channel_idx || channel.index}</p>
<p><strong>Type:</strong> ${channel.type || 'Unknown'}</p>
<p><strong>Key:</strong> ${keyDisplay}</p>
</div>
<div class="col-md-6">
<h6>Feed Subscriptions</h6>
${feedsData.feeds && feedsData.feeds.length > 0
? `<ul>${feedsData.feeds.map(f => `<li><a href="/feeds?id=${f.id}">${f.feed_name || f.feed_url}</a></li>`).join('')}</ul>`
: '<p>No feed subscriptions</p>'}
</div>
</div>
`;
document.getElementById('channelDetailsContent').innerHTML = content;
const modal = new bootstrap.Modal(document.getElementById('channelDetailsModal'));
modal.show();
} catch (error) {
this.showError('Error loading channel details: ' + error.message);
}
}
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 deleteButtons = document.querySelectorAll(`button[onclick*="deleteChannel(${channelIdx})"]`);
let deleteBtn = null;
if (deleteButtons.length > 0) {
deleteBtn = deleteButtons[0];
const originalHtml = deleteBtn.innerHTML;
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
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, originalHtml);
} else {
// Immediate success
this.showSuccess('Channel deleted successfully');
await this.loadChannels();
await this.loadStatistics();
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
} else {
this.showError(result.error || 'Failed to delete channel');
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
} catch (error) {
this.showError('Error deleting channel: ' + error.message);
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
}
} 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);
}
}
}
async pollDeleteOperationStatus(operationId, button = null, originalButtonHtml = 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;
button.innerHTML = originalButtonHtml;
}
return;
} else if (result.status === 'failed') {
this.showError(result.error_message || 'Channel deletion failed');
if (button) {
button.disabled = false;
button.innerHTML = originalButtonHtml;
}
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;
button.innerHTML = originalButtonHtml;
}
// 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') 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('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);
}
}
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');
}
});
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 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);
}
/* Ensure all text in table cells is light in dark mode */
[data-theme="dark"] .table td,
[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 %}