Files
MeshTerm/meshterm/ui/watchtower_screen.py
Jean-Pierre MartineauandClaude Haiku 5.5 820740c9a0 A blank line before each section is one preference, app-wide
"Section spacing" (section_spacing_min_rows, in the Display group)
replaces "Author spacing" and "Spacing from". It is the smallest
terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never (default 24). Each section heading of a list gets the line, and
the chat keeps it between authors and before a new day.

The frame records the terminal rows on the screen before the body
renders (note_terminal_rows), and Screen.section_spacing is the one
test. section_heading marks its row as spaced, and the select list
adds the line, never at the top, after a blank line, or under a
heading. The hand spacers before headings are gone, except the seam
between the region table and its actions, which is two lists.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 22:28:23 -04:00

443 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Watchtower screens: the alert log, the watchlist, and per-node rules.
This is the interactive face of the ``watchtower`` tool. One select-list screen has the
whole feature (the pattern of the persistent backdrop that the config editors use). First
is the alert log, with the newest alert first. An alert that nobody acknowledged starts
with the red marker of the header badge, and Enter acknowledges one alert. After the log is
the watchlist (Enter opens the rule dialog of a node). Last are the actions: star another
node, toggle the rule for new nodes on the whole mesh, and acknowledge or clear alerts in
bulk.
All the code here reads and writes the :class:`~meshterm.core.watch_store.WatchStore`. The
rules themselves run in :mod:`meshterm.services.watchtower`. The menu starts that service
with the other services that always run. This screen also starts it, with no effect if it
already runs, in case the user opens the screen before the menu started it. Nothing
transmits.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
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 icon_lane, icon_mark, 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
#: The display styles for the kinds of alert: alarms are red, warnings are amber, and notes
#: are calm.
_KIND_STYLES = {
"silence": "err",
"snr": "warn",
"new-node": "brand",
"recovered": "ok",
"courier": "accent", # the results of the outbox (refer to services.courier) share the log
}
#: The number of alerts that the screen lists. The store keeps more, but the older ones
#: rarely matter.
_SHOWN_ALERTS = 40
# The sentinels of the menu actions. They are tuples, so that 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) -> str | None:
"""The key of a contact in the watch store: the id of 12 hex digits that 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) -> dict[str, Any] | None:
"""Run the Watchtower screen until the user dismisses it.
Args:
ctx: The shared application context (it must run the interactive TUI).
Returns:
A summary of what happened (for the log of the tool), or ``None`` for a plain exit.
Raises:
RuntimeError: If the caller is 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 - the caller is for the menu only
raise RuntimeError("the watchtower is only available in the menu")
session = ctx.ui.session
await ctx.watchtower.start() # no effect if it runs already, and it normally does
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 the rules render correctly with no contacts
contacts = []
store = ctx.watch_store
# An old watched entry (starred before MeshTerm stored types) uses the advertised type
# from the contact table. The labels of alerts 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 watchlist, as menu rows.
The node type of an alert comes from its label. The advertised type in the contact
table has priority, because it is the newest. The stored type of a watched entry
fills the gaps. Thus the name of an alert gets its glyph also for a node that is no
longer in the contacts of the device.
"""
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. MeshTerm refreshes its rows in place after each
# action. Each action changes the list from which the user selected it: an ack changes
# its row, a star adds a row, and Clear removes several rows. The highlight goes with
# its row to the new place.
menu = SelectScreen(
"Watchtower — alerts & watched nodes",
rows(),
hscroll=True,
# The list itself shows the ←→ scroll, but only while the highlighted alert is
# wider than the screen (refer to SelectScreen.hscroll_hint). It slides only the
# *message*, because each row pins its own lanes (refer to _alert_lanes). The two
# section headings below give the list its ^PgUp/^PgDn jumps and their F1/F2 chips
# with no extra code (SelectScreen.picocalc_lyra_lane). The hint has no room to name
# them beside the ←→ atom, and no other grouped list names them 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], int | None] = lambda key: None,
key_of: Callable[[str], str | None] = lambda name: None,
alert_type_of: Callable[[str], int | None] = lambda label: None,
) -> list:
"""Build the rows of the screen: 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 rule for new nodes on the whole mesh is on.
type_of: Gives a node type for a watch key, for entries that were starred before
MeshTerm stored types.
key_of: Gives a key for the node label of an alert, for its hue. The function adds
the names of the watchlist to this. Thus the alerts of a starred node have a
colour also when the device (and its contact table) is offline.
alert_type_of: Gives the type of the node for the node label of an alert, for the
type glyph before its name (the plain-node ``●`` when the type is unknown).
"""
watched_keys = {entry.name.casefold(): entry.key for entry in watched.values()}
def label_key(label: str) -> str | None:
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,
)
)
# One measured icon column for each action row on this screen, across its headings. The
# terminal draws ✓ and 🗑 in one cell, and ⭐ and 🔔 in two cells. Thus the bulk
# actions once started their words one column to the left of Watch a node… and of the
# toggle for new nodes below them. The set of icons is declared whole and not found from
# the rows that are present. Thus the column does not move when the conditional rows
# appear and disappear. It is empty where the platform draws no icons.
lane = icon_lane(("✓", "🗑", "⭐", "🔔"))
unacked = sum(1 for a in alerts if not a.acked)
acked = len(alerts) - unacked
if unacked:
ack_all = marked_label("✓", f"Acknowledge all ({unacked})", "ok", lane=lane)
items.append(Choice(ack_all, _ACK_ALL))
if acked:
items.append(Choice(marked_label("🗑", "Clear acknowledged", "err", lane=lane), _CLEAR))
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(marked_label("⭐", "Watch a node…", "", lane=lane), _WATCH))
items.append(Separator(" "))
state = "[ok]on[/ok]" if new_node_alerts else "[muted]off[/muted]"
toggle = Text.from_markup(
f"New-node alerts: {state} [muted]— announce first-ever appearances[/muted]"
)
items.append(Choice(Text.assemble(icon_mark("🔔", "", lane), toggle), _TOGGLE_NEW))
return items
def _alert_lanes(
alert: Alert,
key_of: Callable[[str], str | None],
type_of: Callable[[str], int | None] = lambda label: None,
) -> Text:
"""The fixed head of an alert: marker, age, kind, type glyph, node, and the ``—`` lead-in.
The first ``●`` or ``○`` is the *acknowledgement* state. Thus the type marker of the
node itself (``▲`` repeater, ``●`` node, and other markers) is before the node name
instead. It is the marker from the shared palette of the map, in its own type colour.
The function resolves it from the key of the node through ``type_of``. The plain-node
``●`` is for an unknown type, the same as in the watchlist rows. The node label of an
alert that nobody acknowledged has the hue that comes from its key (resolved through
``key_of``, and muted when no key is known). An acknowledged row is muted in all its
parts, with the type glyph, the same as the rest of its history.
The head is separate from the message. Thus the row can measure what it pins out of the
←→ scroll (refer to :attr:`~meshterm.ui.tui.select.Choice.hscroll_from`). These lanes
show *which alert this is*, and the user who reads a long message to its end must not
lose the identity of the row. The ``—`` stays with the head, so the message always has
its lead-in, at each scroll position.
"""
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 that ``←→`` scrolls."""
row = lanes.copy()
row.append(alert.message, style="muted")
return row
def _watched_row(entry: WatchedNode, type_of: Callable[[str], int | None]) -> Text:
"""One watched node as a row: type glyph, name with its hue, its rules, and last heard.
The first glyph is the shared type marker of the node (``▲`` repeater, ``●`` node, and
other markers) in its own type colour. The ``⚠ silent`` at the end signals silence, not
the glyph. The name has the hue that comes from its key (the watch key of the entry, 12
hex digits).
"""
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 dialog to star a node: contacts that are not watched, 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 the rule editor of one watched node until the user dismisses it or removes the star."""
session = ctx.ui.session
store = ctx.watch_store
while True:
entry = store.watched().get(key)
if entry is None:
return
picked = await session.select(
f"Rules — {entry.name}",
_rule_items(entry),
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
#: The number of cells between the widest rule name and its value in the rule dialog.
_RULE_VALUE_GAP = 6
def _rule_items(entry: WatchedNode) -> list:
"""The rows of the rule dialog of one watched node: its two rules with their values, then Stop.
The terminal draws ``🕒`` and ``📶`` in two cells and ``✗`` in one cell. Thus the rows
share one measured icon column. The Stop row once started its words one column to the
left of the rules above it. MeshTerm also measures the value lane, in cells from the
rule names, and does not pad it by hand with spaces. Thus it is in one column for each
width of the icon column, also when the width is nothing, where the platform draws no
icons.
Args:
entry: The watched node whose rules the rows show.
Returns:
The rows of the dialog, with ``"silence"``, ``"snr"``, and ``"unwatch"`` as their
values.
"""
lane = icon_lane(("🕒", "📶", "✗"))
silence = "off" if entry.silence_hours == OFF else f"after {entry.silence_hours} h"
snr = "on" if entry.snr_watch else "off"
rules = (("🕒", "Silence alarm", silence, "silence"), ("📶", "SNR watch", snr, "snr"))
name_w = max(cell_len(name) for _, name, _, _ in rules)
items: list = [
Choice(
Text.assemble(
icon_mark(icon, "", lane),
name,
" " * (name_w - cell_len(name) + _RULE_VALUE_GAP),
value,
),
choice,
)
for icon, name, value, choice in rules
]
items.append(Separator(" "))
items.append(Choice(marked_label("✗", "Stop watching this node", "err", lane=lane), "unwatch"))
return items
async def _pick_silence(ctx: AppContext, key: str, entry: WatchedNode) -> None:
"""Float the dialog to select the silence threshold 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))