mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-03 14:05:42 +00:00
- Updated `pyproject.toml` to include JavaScript files for the web viewer. - Added a new script reference in `base.html` for channel operations. - Improved the channel creation process in `feeds.html` with enhanced UI elements and error handling. - Refactored channel index retrieval in `radio.html` to utilize a centralized method for better maintainability. - Implemented asynchronous channel statistics loading to improve responsiveness during channel operations.
705 lines
30 KiB
HTML
705 lines
30 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">
|
|
<h1 class="mb-4">
|
|
<i class="fas fa-broadcast-tower"></i> Radio Settings
|
|
</h1>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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.initialize();
|
|
}
|
|
|
|
async initialize() {
|
|
await this.loadChannels();
|
|
await this.loadStatistics();
|
|
this.setupEventHandlers();
|
|
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(() => this.loadChannels(), 30000);
|
|
setInterval(() => this.loadStatistics(), 60000);
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|
|
|
|
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'
|
|
});
|
|
|
|
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'
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
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 %}
|
|
|