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>
83 lines
3.2 KiB
Python
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)
|