mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-02 03:22:40 +02:00
feat(regions): Settings > Channels tab with region registry CRUD
Third slice — users can now curate their device-wide region list. No per-channel mapping yet; that's PR #4. - base.html: new Channels tab in the Settings modal with an info banner pointing at regions.meshcore.nz, the list container, and an add-region form. - app.js: loadRegions / addRegion / deleteRegion / setDefaultRegion mirroring the loadContactsSettings / saveContactsSetting pattern. Client -side name validation (isValidRegionName) mirrors the firmware RegionMap::is_name_char byte-rule exactly so users get instant feedback on invalid chars without a round-trip. - api.py: four routes under /api/regions — - GET /api/regions → list registry - POST /api/regions {name} → derive key + insert; 409 dup - DELETE /api/regions/<id> → cascade channel mappings; if the deleted region was firmware default, best-effort clear on device - POST /api/regions/<id>/default → flip DB flag + push CMD 63; if firmware push fails, DB still flips and response includes a non-blocking `warning` for a toast
This commit is contained in:
@@ -18,6 +18,7 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, jsonify, request, send_file, current_app
|
||||
from app.meshcore import cli, parser
|
||||
from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name
|
||||
from app.config import config, runtime_config
|
||||
from app.device_manager import decode_path_len
|
||||
from app.archiver import manager as archive_manager
|
||||
@@ -3930,6 +3931,130 @@ def update_contacts_settings_api():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Regions (MeshCore flood scopes) — Settings > Channels tab
|
||||
# =============================================================================
|
||||
|
||||
@api_bp.route('/regions', methods=['GET'])
|
||||
def list_regions_api():
|
||||
"""List the device's region registry."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'regions': db.list_regions(),
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing regions: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/regions', methods=['POST'])
|
||||
def create_region_api():
|
||||
"""Create a new region. Body: {name: str}. Key is derived from the name."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
ok, err = is_valid_region_name(name)
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'error': err}), 400
|
||||
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
|
||||
if db.get_region_by_name(name) is not None:
|
||||
return jsonify({'success': False, 'error': f'Region "{name}" already exists'}), 409
|
||||
|
||||
import sqlite3
|
||||
try:
|
||||
rid = db.create_region(name, derive_scope_key_hex(name))
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({'success': False, 'error': f'Region "{name}" already exists'}), 409
|
||||
|
||||
return jsonify({'success': True, 'region': db.get_region(rid)}), 201
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating region: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/regions/<int:region_id>', methods=['DELETE'])
|
||||
def delete_region_api(region_id):
|
||||
"""Delete a region. Channels mapped to it have their scope cleared (cascade)."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
|
||||
region = db.get_region(region_id)
|
||||
if region is None:
|
||||
return jsonify({'success': False, 'error': 'Region not found'}), 404
|
||||
|
||||
was_default = bool(region.get('is_default'))
|
||||
db.delete_region(region_id)
|
||||
|
||||
# If we just deleted the firmware default, best-effort clear it on device.
|
||||
warning = None
|
||||
if was_default:
|
||||
dm = _get_dm()
|
||||
if dm and dm.is_connected:
|
||||
try:
|
||||
res = dm.set_default_flood_scope('', '')
|
||||
if not res.get('success'):
|
||||
warning = f"Firmware default not cleared: {res.get('error')}"
|
||||
except Exception as e:
|
||||
warning = f"Firmware default not cleared: {e}"
|
||||
|
||||
out = {'success': True}
|
||||
if warning:
|
||||
out['warning'] = warning
|
||||
return jsonify(out), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting region: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/regions/<int:region_id>/default', methods=['POST'])
|
||||
def set_default_region_api(region_id):
|
||||
"""Mark a region as default in the DB AND push it to the firmware (CMD 63).
|
||||
|
||||
If the firmware push fails the DB flag is still flipped; we return 200 with
|
||||
a non-blocking `warning` so the UI can toast it.
|
||||
"""
|
||||
try:
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
|
||||
region = db.get_region(region_id)
|
||||
if region is None:
|
||||
return jsonify({'success': False, 'error': 'Region not found'}), 404
|
||||
|
||||
db.set_default_region(region_id)
|
||||
|
||||
warning = None
|
||||
dm = _get_dm()
|
||||
if dm and dm.is_connected:
|
||||
try:
|
||||
res = dm.set_default_flood_scope(region['name'], region['key_hex'])
|
||||
if not res.get('success'):
|
||||
warning = f"Firmware push failed: {res.get('error')}"
|
||||
except Exception as e:
|
||||
warning = f"Firmware push failed: {e}"
|
||||
else:
|
||||
warning = 'Device disconnected; firmware default not updated'
|
||||
|
||||
out = {'success': True, 'region': db.get_region(region_id)}
|
||||
if warning:
|
||||
out['warning'] = warning
|
||||
return jsonify(out), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting default region: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Message Retention Settings
|
||||
# =============================================================================
|
||||
|
||||
@@ -2414,6 +2414,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadChatSettings();
|
||||
loadUiSettings();
|
||||
loadContactsSettings();
|
||||
loadRegions();
|
||||
});
|
||||
settingsModal.addEventListener('shown.bs.modal', () => {
|
||||
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
@@ -2436,6 +2437,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initial load so suppress flag is available before user opens Settings
|
||||
loadContactsSettings();
|
||||
|
||||
// Channels tab: region registry
|
||||
const addRegionForm = document.getElementById('addRegionForm');
|
||||
if (addRegionForm) {
|
||||
addRegionForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('newRegionName');
|
||||
const name = (input?.value || '').trim();
|
||||
if (!name) return;
|
||||
addRegion(name, input);
|
||||
});
|
||||
}
|
||||
|
||||
const dmRetryForm = document.getElementById('dmRetrySettingsForm');
|
||||
if (dmRetryForm) {
|
||||
dmRetryForm.addEventListener('submit', (e) => {
|
||||
@@ -2793,6 +2806,134 @@ async function saveContactsSetting(key, value, inputEl) {
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Region Registry (Settings > Channels)
|
||||
// ================================================================
|
||||
|
||||
// Mirrors the firmware RegionMap::is_name_char rule: '-', '$', '#',
|
||||
// digits, or any byte >= 'A'. UTF-8 bytes >= 0x80 pass via byte >= 'A'.
|
||||
function isValidRegionName(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
const bytes = new TextEncoder().encode(name);
|
||||
if (bytes.length === 0 || bytes.length > 30) return false;
|
||||
for (const b of bytes) {
|
||||
if (b === 0x2d || b === 0x24 || b === 0x23) continue; // - $ #
|
||||
if (b >= 0x30 && b <= 0x39) continue; // digits
|
||||
if (b >= 0x41) continue; // >= 'A'
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadRegions() {
|
||||
const listEl = document.getElementById('regionsList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await fetch('/api/regions');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
window.regionRegistry = data.regions || [];
|
||||
renderRegionsList();
|
||||
} catch (e) {
|
||||
console.error('Error loading regions:', e);
|
||||
listEl.innerHTML = '<div class="text-center text-danger small py-2">Failed to load regions</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRegionsList() {
|
||||
const listEl = document.getElementById('regionsList');
|
||||
if (!listEl) return;
|
||||
const regions = window.regionRegistry || [];
|
||||
if (regions.length === 0) {
|
||||
listEl.innerHTML = '<div class="text-center text-muted small py-3">No regions defined. Add one below.</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = regions.map(r => {
|
||||
const isDefault = r.is_default ? 'checked' : '';
|
||||
const keyShort = (r.key_hex || '').slice(0, 8) + '…';
|
||||
return `
|
||||
<div class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="radio" name="regionDefault"
|
||||
id="regionDefault_${r.id}" ${isDefault}
|
||||
onchange="setDefaultRegion(${r.id})">
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div><strong>${escapeHtml(r.name)}</strong></div>
|
||||
<code class="small text-muted" title="${escapeHtml(r.key_hex)}">${keyShort}</code>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteRegion(${r.id}, '${escapeHtml(r.name).replace(/'/g, "\\'")}')"
|
||||
title="Delete region">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function addRegion(name, inputEl) {
|
||||
if (!isValidRegionName(name)) {
|
||||
showNotification('Invalid region name. Allowed: letters, digits, - $ # (max 30 bytes, no spaces).', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/regions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to add region', 'danger');
|
||||
return;
|
||||
}
|
||||
if (inputEl) inputEl.value = '';
|
||||
await loadRegions();
|
||||
} catch (e) {
|
||||
console.error('Error adding region:', e);
|
||||
showNotification('Network error adding region', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRegion(id, name) {
|
||||
if (!confirm(`Delete region "${name}"?\nChannels using this region will revert to no scope.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/regions/${id}`, { method: 'DELETE' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to delete region', 'danger');
|
||||
return;
|
||||
}
|
||||
await loadRegions();
|
||||
} catch (e) {
|
||||
console.error('Error deleting region:', e);
|
||||
showNotification('Network error deleting region', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultRegion(id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/regions/${id}/default`, { method: 'POST' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to set default region', 'danger');
|
||||
await loadRegions(); // snap UI back to server truth
|
||||
return;
|
||||
}
|
||||
if (data.warning) {
|
||||
showNotification(data.warning, 'warning');
|
||||
}
|
||||
// Update the local cache in place so radio stays checked without flicker.
|
||||
(window.regionRegistry || []).forEach(r => { r.is_default = (r.id === id) ? 1 : 0; });
|
||||
} catch (e) {
|
||||
console.error('Error setting default region:', e);
|
||||
showNotification('Network error setting default', 'danger');
|
||||
await loadRegions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send browser notification when new messages arrive
|
||||
* @param {number} channelCount - Number of channels with new messages
|
||||
|
||||
@@ -394,6 +394,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsContacts" type="button">Contacts</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChannels" type="button">Channels</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<!-- Device Settings Tab -->
|
||||
@@ -740,6 +743,33 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsChannels">
|
||||
<div class="alert alert-info py-2 small mb-3 mt-2">
|
||||
<i class="bi bi-info-circle"></i> Only repeaters allowing a region will forward messages tagged with it.
|
||||
Find standardised region names at
|
||||
<a href="https://regions.meshcore.nz/" target="_blank" rel="noopener">regions.meshcore.nz</a>.
|
||||
</div>
|
||||
<h6 class="mb-2">Region Registry</h6>
|
||||
<div id="regionsList" class="list-group mb-3">
|
||||
<div class="text-center text-muted py-3 small">
|
||||
<div class="spinner-border spinner-border-sm"></div> Loading...
|
||||
</div>
|
||||
</div>
|
||||
<form id="addRegionForm" class="row g-2 mb-0">
|
||||
<div class="col-8">
|
||||
<input type="text" class="form-control form-control-sm" id="newRegionName"
|
||||
placeholder="Region name (e.g. pl, pl-ma)" required maxlength="30" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-plus-circle"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-text small">
|
||||
Tip: pick the default region via the radio button. The default is also pushed to the firmware so any untagged channel uses it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
|
||||
Reference in New Issue
Block a user