Files
MeshTerm/meshterm/ui/admin_picker.py
T
Jean-Pierre MartineauandClaude Fable 5 aef80d18bb Repeater admin: set up remote nodes over the mesh
A new Device management entry for administering repeaters and room
servers. The shared credentialed-first picker (extracted from TX
optimize) chooses the node, login runs on remembered passwords with a
one-time prompt otherwise (forgotten on rejection), and the editor
lands in the local device-configuration screen's exact lanes — but
speaking the node's text CLI through a new public
Device.send_remote_command, which brings the repeater-only knobs the
local editor never had: TX delay, Direct TX delay, RX delay, airtime
factor, advert intervals, flood max, repeat, guest password.

Reads cost one paced mesh round trip each, so the editor never
bulk-reads on open: values show what the last read said, age-stamped,
from a new per-node remote.json cache; Read settings refreshes them
under an abortable progress dialog and Apply sends the staged sets
the same way, folding confirmed values back into the cache and
keeping refused ones staged. Advert, clock sync, password change, and
reboot run as confirmed popups.

The Command line is a readline-style screen onto the node's raw CLI:
per-node history persisted across sessions (↑↓ recall, adjacent-dupe
suppression, capped), Tab completion over the known verbs plus every
catalog get/set spelling with ghost-text preview, one command in
flight at a time. The simulator grows a full repeater CLI so all of
it drives against --mock, and a scripted one-shot
(repeater-admin <node> <command…>) covers automation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 04:14:16 -04:00

83 lines
3.2 KiB
Python

"""The admin-node picker: choose a remote node you hold (or will enter) credentials for.
Shared by the tools that administer remote nodes over the mesh — TX optimize and the
repeater admin screen — so "pick the node to manage" reads identically everywhere.
Credentialed nodes lead the list in their own 🔑 section (those are the nodes previously
administered, the likeliest picks), repeaters and room servers follow, then everything
else; each section orders by how recently the node was heard. Any contact with a public
key is offerable, since holding a password is a fact about the *user*, not the node.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from rich.text import Text
from ..core.models import NODE_TYPE_REPEATER, NODE_TYPE_ROOM, Contact
if TYPE_CHECKING:
from ..context import AppContext
async def pick_admin_node(
ctx: "AppContext",
contacts: list[Contact],
*,
title: str,
prompt: str,
) -> Optional[Contact]:
"""Pick a remote node to administer, credentialed and infrastructure nodes first.
Args:
ctx: Shared application context (for the UI surface and the admin store).
contacts: The device's known contacts.
title: The select screen's heading (names the calling feature).
prompt: One line above the list saying what the pick is for.
Returns:
The chosen contact, or ``None`` if cancelled (or there is nothing to pick).
"""
from .tui import Choice, Separator
from .widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
candidates = [c for c in contacts if (c.public_key or c.key_prefix).strip()]
if not candidates:
ctx.ui.note("[err]no contacts with a key — receive an advert first[/err]")
await ctx.ui.present(title=title)
return None
def row(contact: Contact) -> Choice:
glyph, style = _NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
label = Text(glyph, style=style)
label.append(f" {contact.name}")
return Choice(title=label, value=contact.name)
def recency(contact: Contact) -> float:
return -(contact.last_seen.timestamp() if contact.last_seen else 0.0)
remembered = [c for c in candidates if ctx.admin_store.get(c) is not None]
infrastructure = [
c for c in candidates
if c not in remembered and c.node_type in (NODE_TYPE_REPEATER, NODE_TYPE_ROOM)
]
others = [c for c in candidates if c not in remembered and c not in infrastructure]
items: list = []
if remembered:
items.append(Separator("── 🔑 Remembered admins ──", style="accent"))
items.extend(row(c) for c in sorted(remembered, key=recency))
if infrastructure:
items.append(Separator("── Repeaters & rooms ──", style="accent"))
items.extend(row(c) for c in sorted(infrastructure, key=recency))
if others:
items.append(Separator("── Other contacts ──", style="accent"))
items.extend(row(c) for c in sorted(others, key=recency))
items.append(Separator(" "))
items.append(Choice(title="Back", value=None))
choice = await ctx.ui.select(title, items, prompt=prompt, wrap=False)
if choice is None:
return None
return next((c for c in candidates if c.name == choice), None)