mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
feat(repeaters): nest regions from the Add form
The backend already accepted a parent (`region put <name> <parent>`) and the list indented nested regions correctly, but the Add form always created at the root. It now carries an "Inside" picker listing the known regions, defaulting to top level. Verified end to end against MarWoj Mobile Observer: `zzpar` at the root, `zzkid` inside it, the tree coming back as `* / pl / zzpar / zzkid` with the child indented one level, and the firmware refusing to delete a parent that still has children (`Err - not empty`). Also fixes a status code this endpoint got wrong from the start. A repeater answering `Err - not empty` or `Err - unknown region` is enforcing its own rule, not failing as a transport, so it now comes back 200 with `ok: false` like the sibling /action and /neighbours/remove endpoints, rather than 502. 502 was misleading and put a spurious error in the browser console, where it could mask a real one. `dirty` is now computed from `ok`, so a refused action can no longer mark the map as having unsaved changes; the frontend checks both through one helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
538976ab1b
commit
328e1a3998
7 files changed
+63
-19
No files matched your search
+10
-8
@@ -7010,15 +7010,17 @@ def repeater_regions_post(public_key):
|
||||
_repeater_result_status(result)
|
||||
reply = (result.get('reply') or '').strip()
|
||||
# The firmware answers `OK`, `OK - (flood allowed)`, ` home is now x` or
|
||||
# ` default scope is now x`. Anything starting with `Err` is a failure,
|
||||
# and an empty reply means the command never ran.
|
||||
if reply.lower().startswith('err') or not reply:
|
||||
return jsonify({'success': False, 'action': action, 'command': cmd,
|
||||
'reply': reply,
|
||||
'error': reply or 'Empty reply'}), 502
|
||||
return jsonify({'success': True, 'action': action, 'command': cmd,
|
||||
# ` default scope is now x`. Anything starting with `Err` is a refusal
|
||||
# (`Err - not empty` for a region that still has children, `Err - unknown
|
||||
# region`), and an empty reply means the command never ran. A refusal is
|
||||
# the repeater enforcing its own rule, not a transport failure, so it
|
||||
# comes back 200 with ok=false like the sibling action endpoints — the
|
||||
# caller reads `ok`, and `dirty` stays false because nothing changed.
|
||||
ok = not (reply.lower().startswith('err') or not reply)
|
||||
return jsonify({'success': True, 'ok': ok, 'action': action, 'command': cmd,
|
||||
'reply': reply,
|
||||
'dirty': action in _REGION_DIRTYING,
|
||||
'error': None if ok else (reply or 'Empty reply'),
|
||||
'dirty': ok and action in _REGION_DIRTYING,
|
||||
'elapsed_ms': result.get('elapsed_ms')}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error running repeater region action: {e}")
|
||||
|
||||
@@ -2170,10 +2170,14 @@ function renderRegionsPane(body) {
|
||||
<i class="bi bi-scissors me-1"></i>${tHtml('rptmgmt.reg.truncated')}
|
||||
</div>
|
||||
<div class="d-none mb-3" id="regAddForm">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text"><i class="bi bi-signpost"></i></span>
|
||||
<input type="text" class="form-control" id="regNewName" maxlength="30"
|
||||
placeholder="${tHtml('rptmgmt.reg.name_placeholder')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<label class="input-group-text" for="regNewParent">${tHtml('rptmgmt.reg.parent')}</label>
|
||||
<select class="form-select" id="regNewParent"></select>
|
||||
<button type="button" class="btn btn-primary" id="regAddConfirm">
|
||||
${tHtml('rptmgmt.reg.add')}
|
||||
</button>
|
||||
@@ -2182,6 +2186,7 @@ function renderRegionsPane(body) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">${tHtml('rptmgmt.reg.name_help')}</div>
|
||||
<div class="form-text">${tHtml('rptmgmt.reg.parent_help')}</div>
|
||||
<div class="small text-danger d-none" id="regAddError"></div>
|
||||
</div>
|
||||
<div id="regList"></div>
|
||||
@@ -2222,6 +2227,8 @@ function toggleRegionAddForm(show) {
|
||||
if (open) {
|
||||
const input = document.getElementById('regNewName');
|
||||
input.value = '';
|
||||
const parent = document.getElementById('regNewParent');
|
||||
if (parent) parent.value = ''; // a new region goes to the top level unless asked
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
@@ -2230,7 +2237,8 @@ function setRegionsBusy(busy) {
|
||||
_regionsBusy = busy;
|
||||
const pane = document.getElementById('paneBody');
|
||||
if (!pane) return;
|
||||
pane.querySelectorAll('#regRefreshBtn, #regAddBtn, #regAddConfirm, #regDefaultApply, .reg-action')
|
||||
pane.querySelectorAll('#regRefreshBtn, #regAddBtn, #regAddConfirm, #regDefaultApply, ' +
|
||||
'#regNewName, #regNewParent, #regDefaultSelect, .reg-action')
|
||||
.forEach(el => { el.disabled = busy; });
|
||||
const save = document.getElementById('regSaveBtn');
|
||||
if (save) save.disabled = busy || !_regionsDirty;
|
||||
@@ -2283,6 +2291,19 @@ function renderRegionsList() {
|
||||
});
|
||||
}
|
||||
|
||||
// The Add form's parent picker: any known region can hold children, and the
|
||||
// root entry means "top level". Indented to mirror the list above it.
|
||||
const parentSel = document.getElementById('regNewParent');
|
||||
if (parentSel) {
|
||||
const keep = parentSel.value;
|
||||
parentSel.innerHTML = `<option value="">${esc(t('rptmgmt.reg.parent_root'))}</option>` +
|
||||
entries.filter(e => !e.is_root)
|
||||
.map(e => `<option value="${esc(e.name)}">${' '.repeat((e.depth - 1) * 2)}${esc(e.name)}</option>`)
|
||||
.join('');
|
||||
// Survive the refresh that follows every action, unless it is now gone.
|
||||
if (keep && parentSel.querySelector(`option[value="${CSS.escape(keep)}"]`)) parentSel.value = keep;
|
||||
}
|
||||
|
||||
// Default scope: the picker offers the regions this repeater knows about,
|
||||
// plus the unscoped option the firmware spells `<null>`. When that read is
|
||||
// the one that got lost, the picker says so instead of resting on "none" —
|
||||
@@ -2355,6 +2376,14 @@ function regionRowHtml(e) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// A region action can fail two ways: the request never got through (success
|
||||
// false), or the repeater answered and refused (ok false — `Err - not empty`
|
||||
// when a region still has children). Both are failures to the caller, and
|
||||
// neither should mark the map dirty.
|
||||
function regionActionFailed(data) {
|
||||
return !data.success || data.ok === false;
|
||||
}
|
||||
|
||||
// Every region action is one mesh round-trip, so they are serialised behind
|
||||
// _regionsBusy and each one refreshes the tree it just changed.
|
||||
async function postRegionAction(payload) {
|
||||
@@ -2377,7 +2406,7 @@ async function runRegionAction(action, name) {
|
||||
if (_regionsBusy) return;
|
||||
if (action === 'remove' && !window.confirm(t('rptmgmt.reg.confirm_remove', { name }))) return;
|
||||
const data = await postRegionAction({ action, name });
|
||||
if (!data.success) {
|
||||
if (regionActionFailed(data)) {
|
||||
showNotification(data.error || t('rptmgmt.reg.action_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
@@ -2398,15 +2427,18 @@ async function submitNewRegion() {
|
||||
return;
|
||||
}
|
||||
errEl.classList.add('d-none');
|
||||
const data = await postRegionAction({ action: 'add', name });
|
||||
if (!data.success) {
|
||||
const parentEl = document.getElementById('regNewParent');
|
||||
const parent = parentEl ? (parentEl.value || '') : '';
|
||||
const data = await postRegionAction({ action: 'add', name, parent });
|
||||
if (regionActionFailed(data)) {
|
||||
errEl.textContent = data.error || t('rptmgmt.reg.action_failed');
|
||||
errEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
setRegionsDirty(true);
|
||||
toggleRegionAddForm(false);
|
||||
showNotification(t('rptmgmt.reg.added', { name }), 'success');
|
||||
showNotification(parent ? t('rptmgmt.reg.added_under', { name, parent })
|
||||
: t('rptmgmt.reg.added', { name }), 'success');
|
||||
await loadRegions();
|
||||
}
|
||||
|
||||
@@ -2423,7 +2455,7 @@ async function applyDefaultRegion() {
|
||||
}
|
||||
const data = await postRegionAction(name ? { action: 'set_default', name }
|
||||
: { action: 'clear_default' });
|
||||
if (!data.success) {
|
||||
if (regionActionFailed(data)) {
|
||||
showNotification(data.error || t('rptmgmt.reg.action_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
@@ -2434,7 +2466,7 @@ async function applyDefaultRegion() {
|
||||
async function saveRegions() {
|
||||
if (_regionsBusy) return;
|
||||
const data = await postRegionAction({ action: 'save' });
|
||||
if (!data.success) {
|
||||
if (regionActionFailed(data)) {
|
||||
showNotification(data.error || t('rptmgmt.reg.save_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -991,6 +991,7 @@
|
||||
"rptmgmt.reg.action_ok": "Done",
|
||||
"rptmgmt.reg.add": "Add region",
|
||||
"rptmgmt.reg.added": "Region {name} added",
|
||||
"rptmgmt.reg.added_under": "Region {name} added inside {parent}",
|
||||
"rptmgmt.reg.allow_flood": "Allow flood",
|
||||
"rptmgmt.reg.confirm_leave": "You have region changes that have not been saved to the repeater yet.\n\nThey are active now but will be lost when it reboots. Leave without saving?",
|
||||
"rptmgmt.reg.confirm_remove": "Remove region {name} from this repeater?\n\nA region that still has regions nested under it cannot be removed.",
|
||||
@@ -1016,6 +1017,9 @@
|
||||
"rptmgmt.reg.name_help": "Letters, digits and - $ # are allowed; no spaces. Max 30 characters.",
|
||||
"rptmgmt.reg.name_placeholder": "Region name, e.g. pl",
|
||||
"rptmgmt.reg.none": "This repeater has no regions configured.",
|
||||
"rptmgmt.reg.parent": "Inside",
|
||||
"rptmgmt.reg.parent_help": "Nest the new region inside an existing one, or leave it at the top level.",
|
||||
"rptmgmt.reg.parent_root": "— top level —",
|
||||
"rptmgmt.reg.read_failed": "Could not read the regions",
|
||||
"rptmgmt.reg.reading": "Reading regions from the repeater…",
|
||||
"rptmgmt.reg.refresh_title": "Re-read the region list from the repeater",
|
||||
|
||||
@@ -1053,6 +1053,7 @@
|
||||
"rptmgmt.reg.action_ok": "Gotowe",
|
||||
"rptmgmt.reg.add": "Dodaj region",
|
||||
"rptmgmt.reg.added": "Region {name} został dodany",
|
||||
"rptmgmt.reg.added_under": "Region {name} został dodany wewnątrz {parent}",
|
||||
"rptmgmt.reg.allow_flood": "Zezwól na flood",
|
||||
"rptmgmt.reg.confirm_leave": "Masz zmiany regionów, które nie zostały jeszcze zapisane na repeaterze.\n\nDziałają już teraz, ale zostaną utracone po jego restarcie. Wyjść bez zapisywania?",
|
||||
"rptmgmt.reg.confirm_remove": "Usunąć region {name} z tego repeatera?\n\nRegionu, który ma pod sobą zagnieżdżone regiony, nie można usunąć.",
|
||||
@@ -1078,6 +1079,9 @@
|
||||
"rptmgmt.reg.name_help": "Dozwolone są litery, cyfry oraz - $ #; bez spacji. Maks. 30 znaków.",
|
||||
"rptmgmt.reg.name_placeholder": "Nazwa regionu, np. pl",
|
||||
"rptmgmt.reg.none": "Ten repeater nie ma skonfigurowanych regionów.",
|
||||
"rptmgmt.reg.parent": "Wewnątrz",
|
||||
"rptmgmt.reg.parent_help": "Zagnieźdź nowy region wewnątrz istniejącego albo zostaw go na najwyższym poziomie.",
|
||||
"rptmgmt.reg.parent_root": "— najwyższy poziom —",
|
||||
"rptmgmt.reg.read_failed": "Nie udało się odczytać regionów",
|
||||
"rptmgmt.reg.reading": "Odczyt regionów z repeatera…",
|
||||
"rptmgmt.reg.refresh_title": "Odczytaj listę regionów z repeatera ponownie",
|
||||
|
||||
@@ -119,7 +119,7 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are
|
||||
- **Login sessions** — `repeater_login()` waits for `LOGIN_SUCCESS` filtered by the contact's `pubkey_prefix` and stores `{is_admin, permissions, logged_in_at}` in the in-memory `_repeater_sessions` dict (cleared on logout/app restart; the UI auto-relogs with the saved password). All remote endpoints fail fast with 401 `need_login` when no session exists — a repeater only answers pubkeys in its ACL, so requesting without a login would just burn a multi-minute timeout. A wrong password is indistinguishable from an unreachable repeater (the firmware never replies to failed logins), so timeout messages always name both causes
|
||||
- **CLI reply correlation** — `repeater_cmd_wait()` sends one text command and blocks for its reply. Replies arrive as `CONTACT_MSG_RECV` with `txt_type=1` (CLI_DATA) and carry no protocol-level correlation, so correlation = the repeater lock (single command in flight) + a single-slot waiter matched against the sender's 12-hex pubkey prefix at the top of `_on_dm_received`. Matched replies are consumed there and never stored as chat DMs; unmatched CLI replies keep the legacy behavior (stored as a DM) so the Console's fire-and-forget `cmd` flow is unchanged. Wait time derives from the device-suggested timeout (clamped 10–45 s)
|
||||
- **Settings batches** — reads run sequential `get <field>` commands per section and parse the firmware's `> value` replies, reporting per-field errors without failing the batch; writes send only dirty fields and classify each reply as `ok` (starts with `ok`/`password now`), `reboot_required` (reply mentions reboot — e.g. `set radio`), or `failed` (anything else, surfaced verbatim). Connection-level failures abort the rest of the batch
|
||||
- **Regions** — the repeater's flood-scope map, edited over text CLI (`region ...`). Two firmware facts drive the design. `put`/`remove`/`allowf`/`denyf` mutate the map **in RAM only** and are lost on reboot until `region save` writes flash, so the UI stages edits behind an explicit Save and warns before navigating away; `region default` is the exception (the firmware calls `saveRegions()` itself) and so never sets the dirty flag. The bare `region` reply is an indented tree — indent = nesting depth, `^` = home region, trailing ` F` = flood allowed — capped at 160 bytes by `exportTo(reply, 160)`, so `_parse_region_tree()` strips the suffixes right-to-left (a region may legitimately be named `F` or end in `^`) and `_region_tree_truncated()` reports a clipped tail rather than hiding it. Names are validated with the shared `is_valid_region_name()` before any command is built, which also blocks CLI injection through a name. The binary `AnonReqType.REGIONS` request is deliberately unused here: it answers with `exportNamesTo()`, a flat list of *allowed* names with no hierarchy, deny flags or home marker
|
||||
- **Regions** — the repeater's flood-scope map, edited over text CLI (`region ...`). Two firmware facts drive the design. `put`/`remove`/`allowf`/`denyf` mutate the map **in RAM only** and are lost on reboot until `region save` writes flash, so the UI stages edits behind an explicit Save and warns before navigating away; `region default` is the exception (the firmware calls `saveRegions()` itself) and so never sets the dirty flag. The bare `region` reply is an indented tree — indent = nesting depth, `^` = home region, trailing ` F` = flood allowed — capped at 160 bytes by `exportTo(reply, 160)`, so `_parse_region_tree()` strips the suffixes right-to-left (a region may legitimately be named `F` or end in `^`) and `_region_tree_truncated()` reports a clipped tail rather than hiding it. Names are validated with the shared `is_valid_region_name()` before any command is built, which also blocks CLI injection through a name. A repeater that answers `Err - ...` (a region with children, an unknown name) is enforcing its own rule rather than failing as a transport, so that comes back **200 with `ok: false`** like the sibling `/action` and `/neighbours/remove` endpoints — `dirty` stays false and the caller reads `ok`. The binary `AnonReqType.REGIONS` request is deliberately unused here: it answers with `exportNamesTo()`, a flat list of *allowed* names with no hierarchy, deny flags or home marker
|
||||
- **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead
|
||||
- **Actions whitelist** — `advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI
|
||||
|
||||
|
||||
+3
-1
@@ -707,7 +707,9 @@ The screen lists what the repeater currently knows:
|
||||
- Each region below it shows **Flood allowed** or **Flood denied**. Regions can be nested, and a nested one is indented under its parent
|
||||
- The region marked **home** is the repeater's own place in that hierarchy
|
||||
|
||||
The ⋮ menu on a row offers **Allow flood** / **Deny flood**, **Set as home region**, and **Delete region**. A region that still has regions nested under it cannot be deleted — remove the children first. **Add region** takes a name of up to 30 characters; letters, digits and `-`, `$`, `#` are accepted, spaces are not.
|
||||
The ⋮ menu on a row offers **Allow flood** / **Deny flood**, **Set as home region**, and **Delete region**. A region that still has regions nested under it cannot be deleted — remove the children first.
|
||||
|
||||
**Add region** takes a name of up to 30 characters; letters, digits and `-`, `$`, `#` are accepted, spaces are not. The **Inside** picker next to it chooses where the new region goes: leave it on *top level* for an ordinary region, or pick an existing one to nest the new region inside it.
|
||||
|
||||
- **Changes are not permanent until you save them.** Adding, deleting or changing a region takes effect on the repeater immediately, but lives only in its memory: an orange banner appears and the change is forgotten at the next reboot until you press **Save**. This mirrors the firmware, which keeps the region table in RAM and writes it to flash only when told to. Leaving the screen with unsaved changes asks for confirmation first
|
||||
- The **Default scope** card below the list is a separate setting: the region this repeater stamps on flood packets *it* sends itself, as opposed to which ones it forwards for others. Leave it on *none* and its own packets go out unscoped. This one the firmware saves by itself, so it does not need the Save button — the card says so
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
|
||||
|
||||
### Features
|
||||
|
||||
- **Repeater regions can now be managed from the interface, not just the CLI.** A repeater decides which *flood-scoped* packets it passes on from its own list of regions — a name like `pl` that a sender stamps on a packet, which only repeaters allowing that region will forward. Setting that up meant typing `region` commands into the console and reading back an indented text tree. **My Repeaters → Settings → Regions** now shows that tree as a list: each region with its **Flood allowed** / **Flood denied** state, nested regions indented under their parent, and the **home** region marked. A ⋮ menu on each row allows or denies flood, sets the home region, or deletes it, and **Add region** takes a name directly. The wildcard row at the top — *Packets without a region scope* — is the rule for ordinary unstamped packets, and can be switched like any other. Below the list, **Default scope** sets the region this repeater stamps on packets it sends itself.
|
||||
- **Repeater regions can now be managed from the interface, not just the CLI.** A repeater decides which *flood-scoped* packets it passes on from its own list of regions — a name like `pl` that a sender stamps on a packet, which only repeaters allowing that region will forward. Setting that up meant typing `region` commands into the console and reading back an indented text tree. **My Repeaters → Settings → Regions** now shows that tree as a list: each region with its **Flood allowed** / **Flood denied** state, nested regions indented under their parent, and the **home** region marked. A ⋮ menu on each row allows or denies flood, sets the home region, or deletes it, and **Add region** takes a name plus an **Inside** picker, so a region can be nested inside an existing one or left at the top level. The wildcard row at the top — *Packets without a region scope* — is the rule for ordinary unstamped packets, and can be switched like any other. Below the list, **Default scope** sets the region this repeater stamps on packets it sends itself.
|
||||
|
||||
Changes behave the way the firmware does, and the screen is explicit about it: adding, deleting or switching a region takes effect at once but lives only in the repeater's memory, so an orange banner appears until you press **Save**, and leaving with unsaved changes asks first. Without that save the repeater forgets them at its next reboot. The Default scope card is the exception — the firmware stores that one by itself, and the card says so.
|
||||
|
||||
|
||||
Reference in new issue
Block a user