Files
meshcore-bot/modules/web_viewer/templates/radio.html
T
2026-04-20 16:24:40 -04:00

1042 lines
46 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>
<!-- 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.readRadioParams({ 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);
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';
}
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 %}