The rules the last three commits established, stated where they bind: a Navigation section in CLAUDE.md covering the strict stack, `stay`/`Visit` for a hub, `replace_items` for a list whose content is data, and the modal-vs-floating distinction ^W turns on. The two global chords get their one statement on the About page, since neither has a screen to be advertised on. It also says what happens when the host takes Ctrl+W first (VS Code's integrated terminal reads it as close editor) — the key is a convenience, and nothing depends on it. docs/survey-navigation.md gets its ledger updated: A, B and C are closed, with what was actually done to each. D and G stay open, and D is now recorded as the reason Esc Esc was rejected as the pop-all key — on the map, the mesh walk and chat, Esc Esc is already an ordinary peel-then-leave. Also drops an asyncio import the watchtower no longer needs, and an escape sequence in a new docstring that made ast.parse warn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
393 lines
16 KiB
Python
393 lines
16 KiB
Python
"""The Watchtower screens: the alert log, the watchlist, and per-node rules.
|
|
|
|
The interactive face of the ``watchtower`` tool. One select-list screen carries the
|
|
whole feature (the persistent-backdrop pattern the config editors use): the alert log
|
|
newest-first — unacknowledged alerts lead with the header badge's red marker, Enter
|
|
acknowledges one — followed by the watchlist (Enter opens a node's rule popover) and
|
|
the actions: star another node, toggle the mesh-wide new-node rule, acknowledge or
|
|
clear in bulk.
|
|
|
|
Everything here reads and writes the :class:`~meshterm.core.watch_store.WatchStore`;
|
|
the rules themselves run in :mod:`meshterm.services.watchtower`, which the menu starts
|
|
with the other always-on services (and this screen nudges, idempotently, in case it
|
|
is opened before that ever happened). Nothing transmits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
|
|
from rich.text import Text
|
|
|
|
from ..core.models import Contact
|
|
from ..core.watch_store import OFF, SILENCE_CHOICES_H, Alert, WatchedNode
|
|
from ..services.trace_runner import make_name_key_resolver
|
|
from .menus import command_label, marked_label, section_heading
|
|
from .theme import name_style
|
|
from .tui import Choice, Separator
|
|
from .widgets import _DEFAULT_GLYPH, _NODE_GLYPHS, _age_seconds, _format_age
|
|
|
|
if TYPE_CHECKING:
|
|
from ..context import AppContext
|
|
|
|
#: Alert-kind display styles: alarms red, warnings amber, notes calm.
|
|
_KIND_STYLES = {
|
|
"silence": "err",
|
|
"snr": "warn",
|
|
"new-node": "brand",
|
|
"recovered": "ok",
|
|
"courier": "accent", # outbox outcomes (see services.courier) share the log
|
|
}
|
|
|
|
#: How many alerts the screen lists (the store keeps more; the tail rarely matters).
|
|
_SHOWN_ALERTS = 40
|
|
|
|
# Menu action sentinels (tuples so they never collide with alert ids or node keys).
|
|
_WATCH = ("watch",)
|
|
_TOGGLE_NEW = ("toggle-new",)
|
|
_ACK_ALL = ("ack-all",)
|
|
_CLEAR = ("clear",)
|
|
|
|
|
|
def contact_watch_key(contact: Contact) -> Optional[str]:
|
|
"""The watch-store key for a contact: the 12-hex id observations carry."""
|
|
ident = (contact.public_key or contact.key_prefix or "").lower().removeprefix("0x")
|
|
return ident[:12] or None
|
|
|
|
|
|
async def open_watchtower(ctx: "AppContext") -> Optional[dict[str, Any]]:
|
|
"""Run the Watchtower screen until dismissed.
|
|
|
|
Args:
|
|
ctx: The shared application context (must be running the interactive TUI).
|
|
|
|
Returns:
|
|
A summary of what happened (for the tool's log), or ``None`` on plain exit.
|
|
|
|
Raises:
|
|
RuntimeError: If called outside the interactive menu (no full-screen session).
|
|
"""
|
|
from .surface import TuiUi
|
|
from .tui import CANCEL, SelectScreen
|
|
|
|
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
|
raise RuntimeError("the watchtower is only available in the menu")
|
|
session = ctx.ui.session
|
|
await ctx.watchtower.start() # idempotent; normally already running
|
|
|
|
contacts: list[Contact] = []
|
|
try:
|
|
if ctx.is_connected or ctx.settings.connect_on_start:
|
|
contacts = await ctx.devstate.contacts()
|
|
except Exception: # noqa: BLE001 - the log and rules render fine without contacts
|
|
contacts = []
|
|
|
|
store = ctx.watch_store
|
|
# Legacy watched entries (starred before types were stored) fall back to the
|
|
# contact table's advertised type; alert labels resolve back to keys for their hue.
|
|
type_by_key = {
|
|
key: c.node_type
|
|
for c in contacts
|
|
if (key := contact_watch_key(c)) is not None and c.node_type is not None
|
|
}
|
|
contact_key_of = make_name_key_resolver(contacts)
|
|
|
|
def rows() -> list:
|
|
"""The current alert log and watch list, as menu rows.
|
|
|
|
An alert's node type comes by its label — the contact table's advertised type wins
|
|
(freshest), a watched entry's stored type backfills — so an alert's name gets its
|
|
glyph even for a node no longer in the device's contacts.
|
|
"""
|
|
type_by_name: dict[str, int] = {
|
|
entry.name.casefold(): entry.node_type
|
|
for entry in store.watched().values()
|
|
if entry.node_type is not None
|
|
}
|
|
type_by_name.update(
|
|
{c.name.casefold(): c.node_type for c in contacts if c.node_type is not None}
|
|
)
|
|
return _menu_items(
|
|
store.alerts(),
|
|
store.watched(),
|
|
store.new_node_alerts,
|
|
type_of=type_by_key.get,
|
|
key_of=contact_key_of,
|
|
alert_type_of=lambda label: type_by_name.get(label.casefold()),
|
|
)
|
|
|
|
# One screen for the whole visit, its rows refreshed in place after each action — every
|
|
# one of them changes the list it was chosen from (an ack rewrites its row, a star adds
|
|
# one, Clear takes several away), and the highlight rides along to wherever its row went.
|
|
menu = SelectScreen(
|
|
"Watchtower — alerts & watched nodes",
|
|
rows(),
|
|
wrap=False,
|
|
hscroll=True,
|
|
# ←→ scroll is surfaced by the list itself, but only while the highlighted
|
|
# alert actually overflows the width (see SelectScreen.hscroll_hint) — and it
|
|
# slides the *message* alone: each row pins its own lanes (see _alert_lanes).
|
|
# The two section headings below earn the list its ^PgUp/^PgDn jumps and their
|
|
# F1/F2 chips for free (SelectScreen.fkey_lane); the hint has no room to name
|
|
# them beside the ←→ atom, and no other grouped list spells them out either.
|
|
footer_hint="↑↓ move · Enter select/acknowledge · Esc back",
|
|
)
|
|
async with session.stay(menu) as visit:
|
|
while True:
|
|
choice = await visit.result()
|
|
if choice in (None, CANCEL):
|
|
return {"watched": len(store.watched()), "unacked": store.unacked_count()}
|
|
if choice == _WATCH:
|
|
await _pick_node(ctx, contacts)
|
|
elif choice == _TOGGLE_NEW:
|
|
store.set_new_node_alerts(not store.new_node_alerts)
|
|
elif choice == _ACK_ALL:
|
|
store.ack_all()
|
|
elif choice == _CLEAR:
|
|
store.clear_acked()
|
|
elif isinstance(choice, tuple) and choice[0] == "ack":
|
|
store.ack(int(choice[1]))
|
|
elif isinstance(choice, tuple) and choice[0] == "node":
|
|
await _node_rules(ctx, str(choice[1]))
|
|
menu.replace_items(rows())
|
|
|
|
|
|
# --- the menu ---------------------------------------------------------------------------
|
|
|
|
|
|
def _menu_items(
|
|
alerts: list[Alert],
|
|
watched: dict[str, WatchedNode],
|
|
new_node_alerts: bool,
|
|
*,
|
|
type_of: Callable[[str], Optional[int]] = lambda key: None,
|
|
key_of: Callable[[str], Optional[str]] = lambda name: None,
|
|
alert_type_of: Callable[[str], Optional[int]] = lambda label: None,
|
|
) -> list:
|
|
"""Build the screen's rows: alerts, then the watchlist, then the actions.
|
|
|
|
Args:
|
|
alerts: The alert log, newest first.
|
|
watched: The watchlist by canonical id.
|
|
new_node_alerts: Whether the mesh-wide new-node rule is on.
|
|
type_of: Maps a watch key to a node type, for entries starred before types
|
|
were stored.
|
|
key_of: Maps an alert's node label back to a key, for its hue; layered here
|
|
with the watchlist's own names so a starred node's alerts colour even
|
|
when the device (and its contact table) is offline.
|
|
alert_type_of: Maps an alert's node label to that node's type, for the type
|
|
glyph that leads its name (the plain-node ``●`` when unknown).
|
|
"""
|
|
watched_keys = {entry.name.casefold(): entry.key for entry in watched.values()}
|
|
|
|
def label_key(label: str) -> Optional[str]:
|
|
return key_of(label) or watched_keys.get(label.casefold())
|
|
|
|
items: list = [section_heading("Alerts")]
|
|
if not alerts:
|
|
items.append(Separator(" nothing yet — tripped rules land here"))
|
|
for alert in alerts[:_SHOWN_ALERTS]:
|
|
lanes = _alert_lanes(alert, label_key, alert_type_of)
|
|
items.append(
|
|
Choice(
|
|
_alert_row(lanes, alert),
|
|
("ack", alert.ident),
|
|
hscroll_from=lanes.cell_len,
|
|
)
|
|
)
|
|
unacked = sum(1 for a in alerts if not a.acked)
|
|
acked = len(alerts) - unacked
|
|
if unacked:
|
|
items.append(Choice(marked_label("✓", f"Acknowledge all ({unacked})", "ok"), _ACK_ALL))
|
|
if acked:
|
|
items.append(Choice(marked_label("🗑", "Clear acknowledged", "err"), _CLEAR))
|
|
|
|
items.append(Separator(" "))
|
|
items.append(section_heading("Watched nodes"))
|
|
if not watched:
|
|
items.append(Separator(" none starred yet — silence and SNR rules need one"))
|
|
for key in sorted(watched, key=lambda k: watched[k].name.casefold()):
|
|
items.append(Choice(_watched_row(watched[key], type_of), ("node", key)))
|
|
items.append(Choice(command_label("⭐ Watch a node…"), _WATCH))
|
|
|
|
items.append(Separator(" "))
|
|
state = "[ok]on[/ok]" if new_node_alerts else "[muted]off[/muted]"
|
|
items.append(
|
|
Choice(
|
|
command_label(Text.from_markup(
|
|
f"🔔 New-node alerts: {state}"
|
|
" [muted]— announce first-ever appearances[/muted]"
|
|
)),
|
|
_TOGGLE_NEW,
|
|
)
|
|
)
|
|
|
|
return items
|
|
|
|
|
|
def _alert_lanes(
|
|
alert: Alert,
|
|
key_of: Callable[[str], Optional[str]],
|
|
type_of: Callable[[str], Optional[int]] = lambda label: None,
|
|
) -> Text:
|
|
"""An alert's fixed head: marker, age, kind, type glyph, node — and the ``—`` lead-in.
|
|
|
|
The leading ``●``/``○`` is the *acknowledgement* state, so the node's own type marker
|
|
(``▲`` repeater, ``●`` node, …) leads the node name instead — the map's shared marker
|
|
palette in its own type colour, resolved from the node's key through ``type_of`` (the
|
|
plain-node ``●`` when the type is unknown, matching the watchlist rows). An unacked
|
|
alert's node label takes its key-derived hue (resolved through ``key_of``, muted when
|
|
no key is known); an acked row recedes to muted throughout — the type glyph included —
|
|
with the rest of its history.
|
|
|
|
Split from the message so the row can measure what it pins out of the ←→ scroll (see
|
|
:attr:`~meshterm.ui.tui.select.Choice.hscroll_from`): these lanes are *which alert this
|
|
is*, and reading a long message to its end is no reason to lose the row's identity.
|
|
The ``—`` stays with the head so the message still arrives introduced, whatever it is
|
|
slid to.
|
|
"""
|
|
row = Text()
|
|
if alert.acked:
|
|
row.append("○ ", style="muted")
|
|
else:
|
|
row.append("● ", style="err")
|
|
age = _format_age(_age_seconds(alert.when))
|
|
row.append(f"{age:>5} ", style="muted")
|
|
row.append(alert.kind.ljust(10), style=_KIND_STYLES.get(alert.kind, "brand"))
|
|
glyph, glyph_style = _NODE_GLYPHS.get(type_of(alert.label), _DEFAULT_GLYPH)
|
|
row.append(f"{glyph} ", style="muted" if alert.acked else glyph_style)
|
|
if alert.acked:
|
|
row.append(alert.label, style="muted")
|
|
else:
|
|
row.append(alert.label, style=name_style(alert.label, key_of(alert.label)))
|
|
row.append(" — ", style="muted")
|
|
return row
|
|
|
|
|
|
def _alert_row(lanes: Text, alert: Alert) -> Text:
|
|
"""One alert as a row: its fixed lanes, then the message ``←→`` scrolls."""
|
|
row = lanes.copy()
|
|
row.append(alert.message, style="muted")
|
|
return row
|
|
|
|
|
|
def _watched_row(entry: WatchedNode, type_of: Callable[[str], Optional[int]]) -> Text:
|
|
"""One watched node as a row: type glyph, hued name, its rules, and last heard.
|
|
|
|
The leading glyph is the node's shared type marker (``▲`` repeater, ``●`` node, …)
|
|
in its own type colour — silence is signalled by the trailing ``⚠ silent``, not the
|
|
glyph — and the name takes its key-derived hue (the entry's 12-hex watch key).
|
|
"""
|
|
node_type = entry.node_type if entry.node_type is not None else type_of(entry.key)
|
|
glyph, glyph_style = _NODE_GLYPHS.get(node_type, _DEFAULT_GLYPH)
|
|
row = Text()
|
|
row.append(f"{glyph} ", style=glyph_style)
|
|
row.append(entry.name, style=name_style(entry.name, entry.key))
|
|
silence = "off" if entry.silence_hours == OFF else f"{entry.silence_hours} h"
|
|
row.append(f" silence {silence}", style="muted")
|
|
row.append(" · SNR watch " + ("on" if entry.snr_watch else "off"), style="muted")
|
|
if entry.silent_since is not None:
|
|
row.append(" · ⚠ silent", style="err")
|
|
elif entry.last_heard is not None:
|
|
row.append(f" · heard {_format_age(_age_seconds(entry.last_heard))}", style="muted")
|
|
return row
|
|
|
|
|
|
# --- the flows --------------------------------------------------------------------------
|
|
|
|
|
|
async def _pick_node(ctx: "AppContext", contacts: list[Contact]) -> None:
|
|
"""Float the star-a-node picker: unwatched contacts, most recently heard first."""
|
|
session = ctx.ui.session
|
|
store = ctx.watch_store
|
|
candidates = [
|
|
(key, c)
|
|
for c in contacts
|
|
if (key := contact_watch_key(c)) is not None and not store.is_watched(key)
|
|
]
|
|
if not candidates:
|
|
message = (
|
|
"Every known contact is already watched."
|
|
if contacts
|
|
else "No contacts available — connect a device that knows some nodes first."
|
|
)
|
|
await session.message_dialog(Text(message, style="muted"), title="Watch a node")
|
|
return
|
|
|
|
def recency(pair: tuple[str, Contact]) -> float:
|
|
seen = _age_seconds(pair[1].last_seen)
|
|
return seen if seen is not None else float("inf")
|
|
|
|
items: list = []
|
|
for key, contact in sorted(candidates, key=recency):
|
|
glyph, glyph_style = _NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
|
|
row = Text()
|
|
row.append(f"{glyph} ", style=glyph_style)
|
|
row.append(
|
|
contact.name,
|
|
style=name_style(contact.name, contact.public_key or contact.key_prefix),
|
|
)
|
|
row.append(f" heard {_format_age(_age_seconds(contact.last_seen))}", style="muted")
|
|
items.append(Choice(row, (key, contact)))
|
|
picked = await session.select(
|
|
"Watch a node",
|
|
items,
|
|
prompt="Silence and SNR rules will watch it from now on:",
|
|
)
|
|
if picked is None:
|
|
return
|
|
key, contact = picked
|
|
store.watch(
|
|
key, contact.name, last_seen=contact.last_seen, node_type=contact.node_type
|
|
)
|
|
|
|
|
|
async def _node_rules(ctx: "AppContext", key: str) -> None:
|
|
"""Float one watched node's rule editor until dismissed (or the node is unstarred)."""
|
|
session = ctx.ui.session
|
|
store = ctx.watch_store
|
|
while True:
|
|
entry = store.watched().get(key)
|
|
if entry is None:
|
|
return
|
|
silence = "off" if entry.silence_hours == OFF else f"after {entry.silence_hours} h"
|
|
items = [
|
|
Choice(command_label(f"🕒 Silence alarm {silence}"), "silence"),
|
|
Choice(command_label(
|
|
"📶 SNR watch " + ("on" if entry.snr_watch else "off")
|
|
), "snr"),
|
|
Separator(" "),
|
|
Choice(marked_label("✗", "Stop watching this node", "err"), "unwatch"),
|
|
]
|
|
picked = await session.select(
|
|
f"Rules — {entry.name}", items, filterable=False,
|
|
footer_hint="↑↓ move · Enter change · Esc back",
|
|
)
|
|
if picked is None: # Esc
|
|
return
|
|
if picked == "silence":
|
|
await _pick_silence(ctx, key, entry)
|
|
elif picked == "snr":
|
|
store.set_snr_watch(key, not entry.snr_watch)
|
|
elif picked == "unwatch":
|
|
store.unwatch(key)
|
|
return
|
|
|
|
|
|
async def _pick_silence(ctx: "AppContext", key: str, entry: WatchedNode) -> None:
|
|
"""Float the silence-threshold picker for one watched node."""
|
|
session = ctx.ui.session
|
|
items = [Choice("Off — never alarm on silence", OFF)]
|
|
for hours in SILENCE_CHOICES_H:
|
|
label = f"After {hours} h of silence"
|
|
if hours == entry.silence_hours:
|
|
label += " (current)"
|
|
items.append(Choice(label, hours))
|
|
picked = await session.select(
|
|
f"Silence alarm — {entry.name}", items,
|
|
default=entry.silence_hours, filterable=False,
|
|
footer_hint="↑↓ move · Enter set · Esc keep",
|
|
)
|
|
if picked is not None:
|
|
ctx.watch_store.set_silence(key, int(picked))
|