Trace target: pick from the shared sortable list, with a TRACED lane
The target picker was a hand-rolled two-section list ("Recently traced",
then "Contacts") — the one contact enumerator in the app that didn't ride
the shared ContactListScreen. Move it onto that widget so it sorts,
filters, and steers exactly like the Contacts screen and the courier
recipient: full NAME · HEARD · PKTS · KEY lanes, Ctrl+arrow sort ring,
type-to-filter. Every node type is listed — a trace answers "can I reach
this node?" for a repeater or room, not just a companion.
The recency the old section carried becomes a first-class sortable lane:
ContactListScreen grows an optional TRACED column (opt-in via show_traced)
between name and heard, showing how long ago each node was last traced,
recency-heat coloured. It is the picker's default sort, descending, so the
most-recently-traced node leads and never-traced ones gather at the
bottom. A new repository query (target_last_traced) feeds it, folded onto
contacts the same way the old recent-targets list was.
The free-text prompt stays as the fallback when there are no contacts to
list — still the only way to trace a bare key prefix off-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
40c46d3c75
commit
ebb1d40361
6 files changed
+290
-124
No files matched your search
@@ -480,6 +480,32 @@ class Repository:
|
||||
).fetchall()
|
||||
return [row["target"] for row in rows]
|
||||
|
||||
def target_last_traced(self) -> dict[str, datetime]:
|
||||
"""Per trace target, when it was most recently traced (any outcome).
|
||||
|
||||
Feeds the Trace-target picker's ``TRACED`` column and its default sort — how long
|
||||
ago each node was last aimed at, so the picker opens with the most-recently-traced
|
||||
node on top. Keyed by the raw target string the trace was filed under (a contact
|
||||
name or a hex hash), exactly like :meth:`target_trace_counts`; the caller matches
|
||||
its node against those keys. Timed-out attempts count — you *traced* the node
|
||||
whether or not it answered — and the hand-composed path walks filed under
|
||||
:data:`~meshterm.core.models.PATH_TRACE_TARGET` are excluded (they name no target).
|
||||
|
||||
Returns:
|
||||
``target → last-traced timestamp`` (aware UTC) for every distinct non-path-walk
|
||||
target.
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT target, MAX(created_at) AS latest FROM traces "
|
||||
"WHERE target != ? GROUP BY target",
|
||||
(PATH_TRACE_TARGET,),
|
||||
).fetchall()
|
||||
out: dict[str, datetime] = {}
|
||||
for row in rows:
|
||||
if row["latest"]:
|
||||
out[row["target"]] = datetime.fromisoformat(row["latest"])
|
||||
return out
|
||||
|
||||
def target_trace_counts(self) -> dict[str, tuple[int, int]]:
|
||||
"""Per trace target, its ``(successes, total)`` across every stored trace.
|
||||
|
||||
|
||||
+82
-89
@@ -24,6 +24,7 @@ stored history reads the same no matter where it came from.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
@@ -35,8 +36,6 @@ from ..services import trace_runner
|
||||
from ..ui.widgets import stats_panel
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
_BACK = "__back__"
|
||||
|
||||
#: The characters a stored trace target must consist of to be treated as a hex key
|
||||
#: prefix when folding it back to a contact name in the target picker.
|
||||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||||
@@ -118,63 +117,74 @@ class TraceTool(Tool):
|
||||
return ToolResult(summary={"sessions": 1, "traces": traces})
|
||||
|
||||
async def _pick_target(self, ctx: AppContext) -> Optional[str]:
|
||||
"""Pick a trace target: recently traced first, then contacts by recency.
|
||||
"""Pick a trace target from the shared sortable contact list.
|
||||
|
||||
The same ``NAME · TRACED · HEARD · PKTS · KEY`` lanes, Ctrl+arrow sort ring, and
|
||||
type-to-filter the Contacts screen and the courier recipient draw — but with every
|
||||
node type listed (a trace answers *can I reach this node?* for a repeater or room
|
||||
just as for a companion) and an extra ``TRACED`` lane: how long ago each node was
|
||||
last traced. The list opens sorted by ``TRACED`` descending, so the most recently
|
||||
traced node leads and never-traced ones gather at the bottom. Enter commits the
|
||||
highlighted node's name as the target.
|
||||
|
||||
With no known contacts at all the list would be empty, so a free-text prompt takes
|
||||
over instead — also the only way to trace a bare key prefix for a node the device
|
||||
doesn't carry as a contact.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
The chosen target name, or ``None`` if cancelled. With no known contacts and
|
||||
no history, falls back to a free-text prompt so a key prefix can be typed.
|
||||
The chosen target name, or ``None`` if cancelled.
|
||||
"""
|
||||
from ..ui.tui import Choice, Separator
|
||||
from ..ui.widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
|
||||
from ..ui.contactlist import (
|
||||
TRACE_SORT_COLUMNS,
|
||||
TRACE_SORT_OPENS_ASCENDING,
|
||||
ContactListScreen,
|
||||
ContactRow,
|
||||
)
|
||||
from ..ui.surface import TuiUi
|
||||
from ..ui.timemachine_screen import _routing_prefix_bytes
|
||||
from ..ui.widgets import ContactsSort, _contact_pkts
|
||||
|
||||
# Through the session cache: this picker runs on every Trace open, and the contacts
|
||||
# table is a slow round-trip on a busy node — re-reading it here (in front of the
|
||||
# already-cached trace screen) is what kept opening Trace feeling like a stall. See
|
||||
# the note in the ``nodes`` tool and :class:`~meshterm.services.device_state.DeviceState`.
|
||||
# :class:`~meshterm.services.device_state.DeviceState`.
|
||||
contacts = await ctx.devstate.contacts()
|
||||
recent = _recent_targets(ctx.repo.traced_targets(), contacts)
|
||||
|
||||
if not contacts and not recent:
|
||||
if not contacts or not isinstance(ctx.ui, TuiUi):
|
||||
# No contacts to list (or no full-screen session): a free-text prompt is the
|
||||
# only way in — and the only way to trace a bare key prefix off-list.
|
||||
entered = await ctx.ui.text("Target node (name or key prefix):")
|
||||
return entered.strip() if entered else None
|
||||
|
||||
by_name = {c.name: c for c in contacts}
|
||||
|
||||
def row(name: str) -> Choice:
|
||||
contact = by_name.get(name)
|
||||
glyph, style = (
|
||||
_NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
|
||||
if contact is not None
|
||||
else _DEFAULT_GLYPH
|
||||
traced = _last_traced_by_name(contacts, ctx.repo.target_last_traced())
|
||||
counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node}
|
||||
prefix_bytes = await _routing_prefix_bytes(ctx)
|
||||
rows = [
|
||||
ContactRow(
|
||||
value=c,
|
||||
name=c.name,
|
||||
key=c.public_key or c.key_prefix or "",
|
||||
node_type=c.node_type,
|
||||
last_seen=c.last_seen,
|
||||
count=_contact_pkts(c, counts),
|
||||
last_traced=traced.get(c.name),
|
||||
)
|
||||
label = Text(glyph, style=style)
|
||||
label.append(f" {name}")
|
||||
return Choice(title=label, value=name)
|
||||
|
||||
items: list = []
|
||||
listed: set[str] = set()
|
||||
if recent:
|
||||
items.append(Separator("── ⏱ Recently traced ──", style="accent"))
|
||||
for name in recent:
|
||||
items.append(row(name))
|
||||
listed.add(name)
|
||||
remaining = [c for c in contacts if c.name not in listed]
|
||||
if remaining:
|
||||
items.append(Separator("── 👤 Contacts ──", style="accent"))
|
||||
for contact in _by_recency(remaining):
|
||||
items.append(row(contact.name))
|
||||
items.append(Separator(" "))
|
||||
items.append(Choice(title="Back", value=_BACK))
|
||||
|
||||
choice = await ctx.ui.select(
|
||||
"Trace target — pick a target", items, wrap=False
|
||||
for c in contacts
|
||||
]
|
||||
picker = ContactListScreen(
|
||||
"Trace target — pick a target",
|
||||
rows=rows,
|
||||
prefix_bytes=prefix_bytes,
|
||||
sort=ContactsSort.from_name(
|
||||
"traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING
|
||||
),
|
||||
footer_hint="↑↓ move · ^←→↑↓ sort · type filter · Enter select · Esc back",
|
||||
show_traced=True,
|
||||
)
|
||||
if choice in (None, _BACK):
|
||||
return None
|
||||
return str(choice)
|
||||
chosen = await ctx.ui.session.run_screen(picker)
|
||||
return chosen.name if isinstance(chosen, Contact) else None
|
||||
|
||||
# -- scripted (CLI) -------------------------------------------------------------
|
||||
|
||||
@@ -387,61 +397,44 @@ async def _trace_once_cli(
|
||||
return ToolResult(summary=summary, message=message)
|
||||
|
||||
|
||||
def _recent_targets(stored: list[str], contacts: list[Contact]) -> list[str]:
|
||||
"""Clean the stored recent-target names for the picker's *Recently traced* section.
|
||||
def _last_traced_by_name(
|
||||
contacts: list[Contact], traced: dict[str, datetime]
|
||||
) -> dict[str, datetime]:
|
||||
"""Map each contact's name to when that node was last traced.
|
||||
|
||||
Targets are recorded exactly as the user addressed them, so the same node can
|
||||
appear once as a contact name and again as a raw hex prefix typed some other day.
|
||||
Each stored target is folded back to its contact's current name when it matches one
|
||||
(by name, case-insensitively, or as a prefix of a contact's public key), then
|
||||
deduplicated with order preserved — so every node shows once, under the name the
|
||||
rest of the picker uses.
|
||||
The Trace picker's ``TRACED`` lane and default sort read this. Stored trace targets are
|
||||
filed exactly as the user addressed them — a contact name one day, a raw hex key prefix
|
||||
another — so each target is folded onto the contact it names (by name, case-insensitively,
|
||||
or as a prefix of a contact's public key) and the *latest* trace time wins, so a node
|
||||
traced under both spellings still shows one honest "last traced" age. This is the inverse
|
||||
of the picker's old recent-targets fold, kept per-contact rather than as a name list.
|
||||
|
||||
Args:
|
||||
stored: Recent trace destinations, most recent first, as recorded.
|
||||
contacts: The device's current contacts to fold names against.
|
||||
contacts: The device's current contacts.
|
||||
traced: ``target → last-traced time`` from
|
||||
:meth:`~meshterm.persistence.repository.Repository.target_last_traced`.
|
||||
|
||||
Returns:
|
||||
The display names, most recently traced first, one per node.
|
||||
``contact name → last-traced time`` for every contact the history can place.
|
||||
"""
|
||||
by_fold = {c.name.casefold(): c.name for c in contacts}
|
||||
out: dict[str, datetime] = {}
|
||||
|
||||
def fold(target: str) -> str:
|
||||
def note(name: str, when: datetime) -> None:
|
||||
current = out.get(name)
|
||||
if current is None or when > current:
|
||||
out[name] = when
|
||||
|
||||
for target, when in traced.items():
|
||||
named = by_fold.get(target.casefold())
|
||||
if named is not None:
|
||||
return named
|
||||
note(named, when)
|
||||
continue
|
||||
needle = target.lower().removeprefix("0x")
|
||||
# Only fold plausible key prefixes (≥2 bytes of hex) — a short hex-looking
|
||||
# *name* like "ace" must not be mistaken for an address.
|
||||
# Only fold plausible key prefixes (≥2 bytes of hex) — a short hex-looking *name*
|
||||
# like "ace" must not be mistaken for an address.
|
||||
if len(needle) >= 4 and all(ch in _HEX_DIGITS for ch in needle):
|
||||
for c in contacts:
|
||||
if (c.public_key or "").lower().startswith(needle):
|
||||
return c.name
|
||||
return target
|
||||
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for target in stored:
|
||||
name = fold(target)
|
||||
if name.casefold() not in seen:
|
||||
seen.add(name.casefold())
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _by_recency(contacts: list[Contact]) -> list[Contact]:
|
||||
"""Order contacts most-recently-heard first (never-heard last, alphabetically).
|
||||
|
||||
Args:
|
||||
contacts: The contacts to order.
|
||||
|
||||
Returns:
|
||||
A new sorted list; the input is left untouched.
|
||||
"""
|
||||
|
||||
def key(c: Contact) -> tuple:
|
||||
heard = c.last_seen is not None and getattr(c.last_seen, "tzinfo", None) is not None
|
||||
stamp = c.last_seen.timestamp() if heard else 0.0
|
||||
return (not heard, -stamp, c.name.casefold())
|
||||
|
||||
return sorted(contacts, key=key)
|
||||
for contact in contacts:
|
||||
if (contact.public_key or "").lower().startswith(needle):
|
||||
note(contact.name, when)
|
||||
return out
|
||||
+91
-27
@@ -51,6 +51,11 @@ _HASH_MIN = 8
|
||||
#: and the key lane takes the rest (see :meth:`ContactListScreen._lane_widths`).
|
||||
_LEAD = 2 + 2 + (2 + 5) + (2 + 5) + 3
|
||||
|
||||
#: The optional TRACED lane's width in cells: a 2-cell gap plus a 6-wide age field (wide
|
||||
#: enough for the ``TRACED`` header). Only the Trace-target picker shows it (``show_traced``),
|
||||
#: inserted between the name lane and HEARD; every other contact list omits it (and its width).
|
||||
_TRACED_LANE = 2 + 6
|
||||
|
||||
#: The muted ``(you)`` tag on the own-node lane (see :func:`_lane`), its width folded into
|
||||
#: the name lane's content sizing so the tag never truncates.
|
||||
_YOU_TAG = " (you)"
|
||||
@@ -66,6 +71,17 @@ SORT_OPENS_ASCENDING: dict[str, bool] = {
|
||||
"name": True, "heard": True, "packets": False, "hash": True,
|
||||
}
|
||||
|
||||
#: The Trace-target picker's wider sort ring. It adds a ``traced`` column — how long ago the
|
||||
#: node was last traced — between name and heard, and opens on it *descending*: the
|
||||
#: most-recently-traced node on top, never-traced gathered at the bottom (a trace time is the
|
||||
#: metric, so descending is newest-first — see :func:`_ordered`). The picker builds its
|
||||
#: :class:`~meshterm.ui.widgets.ContactsSort` over these, so the Ctrl+arrows walk all five
|
||||
#: columns; a list without the ``traced`` lane keeps to :data:`SORT_COLUMNS`.
|
||||
TRACE_SORT_COLUMNS: tuple[str, ...] = ("name", "traced", "heard", "packets", "hash")
|
||||
TRACE_SORT_OPENS_ASCENDING: dict[str, bool] = {
|
||||
"name": True, "traced": False, "heard": True, "packets": False, "hash": True,
|
||||
}
|
||||
|
||||
#: The cyan the active sort column and its triangle are lit in, matching the static Contacts
|
||||
#: table's header (see :func:`~meshterm.ui.widgets._sort_header`) so sort cues read
|
||||
#: identically everywhere.
|
||||
@@ -93,6 +109,10 @@ class ContactRow:
|
||||
last_seen: When the contact was last heard (aware UTC) — fills the heard lane,
|
||||
coloured by recency heat; ``None`` reads ``never`` in the cold style.
|
||||
count: The packet tally; ``None`` renders a faint ``—`` (never overheard).
|
||||
last_traced: When this node was last traced (aware UTC) — fills the optional
|
||||
``TRACED`` lane (only shown when the list is built with ``show_traced``),
|
||||
coloured by recency heat; ``None`` reads ``never``. Ignored by lists that don't
|
||||
show the lane.
|
||||
you: Whether this is our own node: the ``★`` marker, the pure-white ``you`` name
|
||||
style with a muted ``(you)`` tag, faint ``—`` heard/packet lanes (we never
|
||||
overhear ourselves), pinned above the sorted block whatever the sort.
|
||||
@@ -104,15 +124,17 @@ class ContactRow:
|
||||
node_type: Optional[int] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
count: Optional[int] = None
|
||||
last_traced: Optional[datetime] = None
|
||||
you: bool = False
|
||||
|
||||
|
||||
def _header(name_w: int, sort: ContactsSort) -> Text:
|
||||
def _header(name_w: int, sort: ContactsSort, show_traced: bool = False) -> Text:
|
||||
"""Column labels over the contact lanes (see :func:`_lane`).
|
||||
|
||||
The lanes read ``NAME · HEARD · PKTS · KEY``, matching the row builder. The four
|
||||
leading spaces cover the select screen's pointer column (2 cells) plus the one-cell
|
||||
type glyph and its gap, so each label lands over its lane.
|
||||
The lanes read ``NAME · HEARD · PKTS · KEY`` — or ``NAME · TRACED · HEARD · PKTS · KEY``
|
||||
when ``show_traced`` inserts the Trace picker's extra lane — matching the row builder.
|
||||
The four leading spaces cover the select screen's pointer column (2 cells) plus the
|
||||
one-cell type glyph and its gap, so each label lands over its lane.
|
||||
|
||||
All four columns are sortable, so any one can be the active sort. The active column's
|
||||
label *and* its direction triangle (``▲`` ascending, ``▼`` descending) are lit cyan
|
||||
@@ -136,7 +158,11 @@ def _header(name_w: int, sort: ContactsSort) -> Text:
|
||||
|
||||
header = Text(" ", style="muted") # pointer (2) + the row's type glyph and gap (2)
|
||||
column(header, "NAME", "name", pad_to=name_w)
|
||||
header.append(" ", style="muted") # gap to the HEARD lane
|
||||
header.append(" ", style="muted") # gap to the first metric lane
|
||||
if show_traced:
|
||||
# The Trace picker's extra lane, sized 6 wide (its own label's width) so the
|
||||
# ``TRACED`` header fits; the column helper's 2-cell reserve is the gap to HEARD.
|
||||
column(header, "TRACED", "traced")
|
||||
column(header, "HEARD", "heard")
|
||||
column(header, f"{'PKTS':>5}", "packets")
|
||||
# KEY sits one cell further out than the other lanes so a packets-sort triangle —
|
||||
@@ -146,16 +172,18 @@ def _header(name_w: int, sort: ContactsSort) -> Text:
|
||||
return header
|
||||
|
||||
|
||||
def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
|
||||
def _you_lane(
|
||||
row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int, show_traced: bool = False
|
||||
) -> Text:
|
||||
"""Our own node's lane — laid out exactly like :func:`_lane`'s regular rows.
|
||||
|
||||
Drawn like the map and the static table draw us: the ``★`` self marker (yellow), the
|
||||
name in the pure-white ``you`` style with a muted ``(you)`` tag, and our key with its
|
||||
hash lit at the routing width, filling the flexing key lane — our 64-hex key is longer
|
||||
than any lane, so it shows as many digits as fit and ellipsizes. The heard and packet
|
||||
lanes read a faint ``—``: we never overhear ourselves, so there is no reception age or
|
||||
count to show. With no reachable device to name us, the name falls back to a bare
|
||||
``you`` and the key to ``?``.
|
||||
lanes (and the optional ``TRACED`` lane) read a faint ``—``: we never overhear or trace
|
||||
ourselves, so there is no reception age, count, or trace age to show. With no reachable
|
||||
device to name us, the name falls back to a bare ``you`` and the key to ``?``.
|
||||
"""
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
text.append(_SELF[0], style=_SELF[1])
|
||||
@@ -172,6 +200,9 @@ def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> T
|
||||
else:
|
||||
text.append(fit_cells(row.name or "you", name_w), style="you")
|
||||
text.append(" ")
|
||||
if show_traced:
|
||||
text.append(f"{'—':>6}", style="faint") # traced: we never trace ourselves
|
||||
text.append(" ")
|
||||
text.append(f"{'—':>5}", style="faint") # heard: we don't hear ourselves
|
||||
text.append(" ")
|
||||
text.append(f"{'—':>5}", style="faint") # packets: nothing to count
|
||||
@@ -183,18 +214,21 @@ def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> T
|
||||
return text
|
||||
|
||||
|
||||
def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
|
||||
def _lane(
|
||||
row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int, show_traced: bool = False
|
||||
) -> Text:
|
||||
"""One contact as fixed, colour-coded lanes under :func:`_header`'s columns.
|
||||
|
||||
The type glyph leads (the app's shared marker palette), so the mark reads ``▲`` for a
|
||||
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name takes
|
||||
the contact's hash-derived palette hue (a nameless contact's ``unknown`` placeholder
|
||||
stays muted — colour marks a name, and the hash lane already carries the identity) and
|
||||
the flexing name lane (``name_w`` cells). The last-heard age follows, glowing with
|
||||
recency heat (brighter = fresher) so a freshly heard contact still reads hot at a
|
||||
glance; then the packet count, right-aligned — a contact never overheard reads a faint
|
||||
``—``; the key closes the row in the shared key widget, its hash lit at the device's
|
||||
routing width, or a muted ``?`` when no key is known at all. An own-node row
|
||||
the flexing name lane (``name_w`` cells). With ``show_traced`` the last-traced age comes
|
||||
next (the Trace picker's lane, recency-heat coloured, ``never`` cold). The last-heard age
|
||||
follows, glowing with recency heat (brighter = fresher) so a freshly heard contact still
|
||||
reads hot at a glance; then the packet count, right-aligned — a contact never overheard
|
||||
reads a faint ``—``; the key closes the row in the shared key widget, its hash lit at the
|
||||
device's routing width, or a muted ``?`` when no key is known at all. An own-node row
|
||||
(:attr:`ContactRow.you`) takes its own drawing — see :func:`_you_lane`.
|
||||
|
||||
Args:
|
||||
@@ -203,9 +237,11 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
|
||||
prefix_bytes: The hash width in bytes to light at the head of the key.
|
||||
hash_w: The flexing key lane's width in cells (a short key pads out to it; see
|
||||
:func:`~meshterm.ui.widgets.highlighted_hash`).
|
||||
show_traced: Whether to draw the ``TRACED`` lane (last-traced age) between the name
|
||||
and heard lanes — only the Trace-target picker does.
|
||||
"""
|
||||
if row.you:
|
||||
return _you_lane(row, name_w, prefix_bytes, hash_w)
|
||||
return _you_lane(row, name_w, prefix_bytes, hash_w, show_traced)
|
||||
glyph, glyph_style = _NODE_GLYPHS.get(row.node_type, _DEFAULT_GLYPH)
|
||||
secs = _age_seconds(row.last_seen)
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
@@ -216,6 +252,10 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
|
||||
style=name_style(row.name, row.key) if row.name else "muted",
|
||||
)
|
||||
text.append(" ")
|
||||
if show_traced:
|
||||
tsecs = _age_seconds(row.last_traced)
|
||||
text.append(f"{_format_age(tsecs):>6}", style=_recency_style(tsecs))
|
||||
text.append(" ")
|
||||
text.append(f"{_format_age(secs):>5}", style=_recency_style(secs))
|
||||
text.append(" ")
|
||||
if row.count is None:
|
||||
@@ -233,11 +273,14 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
|
||||
def _ordered(rows: list[ContactRow], sort: ContactsSort) -> list[ContactRow]:
|
||||
"""Order the sortable rows by the active sort (own-node rows are pinned elsewhere).
|
||||
|
||||
The four columns' natural metrics: name A→Z, ``heard`` by age so ascending is
|
||||
The columns' natural metrics: name A→Z, ``heard`` by age so ascending is
|
||||
most-recently-heard first (a never-heard row gathers at the old end), ``packets`` by
|
||||
count, ``hash`` by the displayed key (ascending = ``0`` → ``f``). A name-ascending
|
||||
pre-sort is the stable tiebreak, so two contacts sharing a metric keep an A→Z order
|
||||
under both directions rather than flipping with the primary key.
|
||||
count, ``hash`` by the displayed key (ascending = ``0`` → ``f``). The optional
|
||||
``traced`` column sorts by *trace time*, so descending — its natural open (see
|
||||
:data:`TRACE_SORT_OPENS_ASCENDING`) — is most-recently-traced first, with never-traced
|
||||
rows carrying ``-inf`` so they gather at the bottom. A name-ascending pre-sort is the
|
||||
stable tiebreak, so two contacts sharing a metric keep an A→Z order under both
|
||||
directions rather than flipping with the primary key.
|
||||
"""
|
||||
|
||||
def key_name(row: ContactRow) -> str:
|
||||
@@ -250,6 +293,13 @@ def _ordered(rows: list[ContactRow], sort: ContactsSort) -> list[ContactRow]:
|
||||
return row.count or 0
|
||||
if sort.column == "hash":
|
||||
return row.key
|
||||
if sort.column == "traced":
|
||||
# Sort by trace time (epoch), so descending = newest trace first and a
|
||||
# never-traced row (-inf) always lands last, whichever direction is active.
|
||||
when = row.last_traced
|
||||
if when is None or getattr(when, "tzinfo", None) is None:
|
||||
return float("-inf")
|
||||
return when.timestamp()
|
||||
secs = _age_seconds(row.last_seen) # "heard": ascending = freshest first
|
||||
return secs if secs is not None else float("inf")
|
||||
|
||||
@@ -263,7 +313,10 @@ class ContactListScreen(SelectScreen):
|
||||
|
||||
The rows run in aligned lanes — name (in the contact's hash-derived palette hue),
|
||||
last-heard age (coloured by recency heat), packet count, and key — own-node rows
|
||||
first, then the rest in the active sort order. The lanes anchor to the left: the name lane is sized to its widest name (not
|
||||
first, then the rest in the active sort order. Building with ``show_traced`` inserts
|
||||
one more lane, ``TRACED`` (how long ago the node was last traced), between name and
|
||||
heard — the Trace-target picker only, which opens sorted by it. The lanes anchor to
|
||||
the left: the name lane is sized to its widest name (not
|
||||
the terminal), so the columns stay put as the window widens and the freed width flows
|
||||
to the key lane, which shows each key as fully as it fits (see :meth:`_lane_widths`).
|
||||
It is a full-screen list, not a floating popup.
|
||||
@@ -290,6 +343,7 @@ class ContactListScreen(SelectScreen):
|
||||
prompt: str = "",
|
||||
lead: Optional[list] = None,
|
||||
footer_hint: str = _HINT,
|
||||
show_traced: bool = False,
|
||||
) -> None:
|
||||
"""Build the list over already-resolved rows.
|
||||
|
||||
@@ -300,15 +354,20 @@ class ContactListScreen(SelectScreen):
|
||||
prefix_bytes: The hash width in bytes to light at the head of each key.
|
||||
sort: The sort state, mutated in place by the Ctrl+arrows — pass the same
|
||||
instance across re-opens so the chosen order persists. Its ring should
|
||||
span :data:`SORT_COLUMNS` for the hash column to be reachable.
|
||||
span :data:`SORT_COLUMNS` for the hash column to be reachable (or
|
||||
:data:`TRACE_SORT_COLUMNS` when ``show_traced`` adds the traced column).
|
||||
prompt: An optional instruction shown above the list.
|
||||
lead: Rows (choices/separators) drawn above the column header — the Time
|
||||
Machine's whole-mesh row and its section heading; ``None`` for none.
|
||||
footer_hint: Footer key hint; the default advertises the full grammar.
|
||||
show_traced: Whether to draw the ``TRACED`` lane (last-traced age) between the
|
||||
name and heard lanes — the Trace-target picker only. Pair it with a ``sort``
|
||||
whose ring includes the ``traced`` column.
|
||||
"""
|
||||
self._contact_rows = rows
|
||||
self._prefix_bytes = prefix_bytes
|
||||
self._sort = sort
|
||||
self._show_traced = show_traced
|
||||
self._lead = list(lead) if lead else []
|
||||
# Provisional until the first render learns the true width (see render_body).
|
||||
self._name_w = _NAME_MIN
|
||||
@@ -327,13 +386,16 @@ class ContactListScreen(SelectScreen):
|
||||
Own-node rows lead the lanes and stay first whatever the sort: only the block
|
||||
below them reorders (see :func:`_ordered`)."""
|
||||
items: list = list(self._lead)
|
||||
items.append(Separator(_header(self._name_w, self._sort)))
|
||||
items.append(Separator(_header(self._name_w, self._sort, self._show_traced)))
|
||||
pinned = [row for row in self._contact_rows if row.you]
|
||||
rest = [row for row in self._contact_rows if not row.you]
|
||||
for row in (*pinned, *_ordered(rest, self._sort)):
|
||||
items.append(
|
||||
Choice(
|
||||
_lane(row, self._name_w, self._prefix_bytes, self._hash_w),
|
||||
_lane(
|
||||
row, self._name_w, self._prefix_bytes, self._hash_w,
|
||||
self._show_traced,
|
||||
),
|
||||
row.value,
|
||||
)
|
||||
)
|
||||
@@ -380,11 +442,13 @@ class ContactListScreen(SelectScreen):
|
||||
window grows; it only yields when a very long name would starve the key lane past
|
||||
its :data:`_HASH_MIN` floor. The key lane then takes all the width the fixed
|
||||
lanes and the name lane leave, so keys show as fully as they fit — a heard id's 12
|
||||
hex digits with room to spare, a 64-hex key ellipsized to the lane.
|
||||
hex digits with room to spare, a 64-hex key ellipsized to the lane. The optional
|
||||
``TRACED`` lane widens the fixed lead by :data:`_TRACED_LANE` when shown.
|
||||
"""
|
||||
name_cap = max(_NAME_MIN, width - _LEAD - _HASH_MIN)
|
||||
lead = _LEAD + (_TRACED_LANE if self._show_traced else 0)
|
||||
name_cap = max(_NAME_MIN, width - lead - _HASH_MIN)
|
||||
name_w = max(_NAME_MIN, min(self._widest_name(), name_cap))
|
||||
hash_w = max(_HASH_MIN, width - _LEAD - name_w)
|
||||
hash_w = max(_HASH_MIN, width - lead - name_w)
|
||||
return name_w, hash_w
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
|
||||
@@ -194,6 +194,22 @@ def test_target_trace_counts_tallies_successes_and_totals(repo: Repository) -> N
|
||||
assert PATH_TRACE_TARGET not in counts # path walks name no target
|
||||
|
||||
|
||||
def test_target_last_traced_keeps_latest_per_target(repo: Repository) -> None:
|
||||
"""Per target, the most recent trace time — failures counted, path walks excluded."""
|
||||
run = repo.start_run("trace", {})
|
||||
early = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc)
|
||||
late = datetime(2026, 1, 12, 18, 0, tzinfo=timezone.utc)
|
||||
repo.record_trace(run, _make_trace("Alice", ("r", 1.0), ts=early))
|
||||
repo.record_trace(run, _make_trace("Alice", success=False, ts=late)) # newer, timed out
|
||||
repo.record_trace(run, _make_trace("Bob", ("r", 1.0), ts=early))
|
||||
repo.record_trace(run, _make_trace(PATH_TRACE_TARGET, ("r", 3.0), ts=late))
|
||||
|
||||
last = repo.target_last_traced()
|
||||
assert last["Alice"] == late # the failed-but-newer trace wins — you still traced it
|
||||
assert last["Bob"] == early
|
||||
assert PATH_TRACE_TARGET not in last # path walks name no target
|
||||
|
||||
|
||||
# -- latest_trace edge cases -----------------------------------------------
|
||||
|
||||
|
||||
|
||||
+19
-8
@@ -62,21 +62,32 @@ def test_parse_trace_path_rejects_mixed_widths() -> None:
|
||||
trace_runner.parse_trace_path("3d,a1b2c3", contacts=[])
|
||||
|
||||
|
||||
def test_recent_targets_folds_hex_to_names_and_dedupes() -> None:
|
||||
"""Stored targets collapse to contact names so a node shows once in the picker.
|
||||
def test_last_traced_by_name_folds_hex_to_contacts_and_keeps_latest() -> None:
|
||||
"""The Trace picker's TRACED lane folds every stored target onto its contact.
|
||||
|
||||
The same node traced as "alice" one day and by its key prefix another must appear
|
||||
a single time under its contact name; a hex-looking *name* stays a name, and
|
||||
unknown prefixes pass through untouched.
|
||||
A node traced as "alice" one day and by its key prefix another shows one honest last-
|
||||
traced time — the latest of the two. A hex-looking *name* stays a name (matched by
|
||||
name, not address), and an unknown prefix that names no contact simply drops out.
|
||||
"""
|
||||
from meshterm.tools.trace import _recent_targets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshterm.tools.trace import _last_traced_by_name
|
||||
|
||||
contacts = [
|
||||
Contact(name="Alice", public_key="d4e5f6a7" + "00" * 28, key_prefix="d4e5f6a7"),
|
||||
Contact(name="cafe", public_key="12ab34cd" + "00" * 28, key_prefix="12ab34cd"),
|
||||
]
|
||||
stored = ["d4e5f6", "alice", "Alice", "cafe", "beefbeef", "d4e5f6a7"]
|
||||
assert _recent_targets(stored, contacts) == ["Alice", "cafe", "beefbeef"]
|
||||
|
||||
def when(day: int) -> datetime:
|
||||
return datetime(2026, 7, day, tzinfo=timezone.utc)
|
||||
|
||||
traced = {
|
||||
"d4e5f6": when(1), # Alice by an early key-prefix trace
|
||||
"Alice": when(9), # …and by name, more recently — the latest must win
|
||||
"cafe": when(5), # a hex-looking *name*, matched as a name
|
||||
"beefbeef": when(3), # a prefix that names no contact — dropped
|
||||
}
|
||||
assert _last_traced_by_name(contacts, traced) == {"Alice": when(9), "cafe": when(5)}
|
||||
|
||||
|
||||
def test_node_type_resolver_matches_hop_hash_to_contact_type() -> None:
|
||||
|
||||
@@ -666,6 +666,62 @@ def test_picker_header_highlights_only_the_active_sort_column() -> None:
|
||||
assert not any(any(other in seg for other in ("NAME", "HEARD", "KEY")) for seg in lit)
|
||||
|
||||
|
||||
def test_traced_lane_shows_only_when_enabled() -> None:
|
||||
"""The optional TRACED lane sits between NAME and HEARD — and only with show_traced."""
|
||||
from meshterm.ui.contactlist import (
|
||||
TRACE_SORT_COLUMNS,
|
||||
TRACE_SORT_OPENS_ASCENDING,
|
||||
ContactRow,
|
||||
_header,
|
||||
_lane,
|
||||
)
|
||||
from meshterm.ui.widgets import ContactsSort
|
||||
|
||||
sort = ContactsSort.from_name("traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING)
|
||||
# Off (the default of every other list): no TRACED column, lanes read NAME·HEARD·PKTS·KEY.
|
||||
assert "TRACED" not in _header(10, ContactsSort.from_name("heard")).plain
|
||||
# On: TRACED lands between NAME and HEARD, and opens as the default sort (descending).
|
||||
header = _header(10, sort, show_traced=True).plain
|
||||
assert header.index("NAME") < header.index("TRACED") < header.index("HEARD")
|
||||
assert "TRACED ▼" in header
|
||||
|
||||
row = ContactRow(
|
||||
value="x", name="Poly", key="3d" * 6,
|
||||
last_seen=utcnow() - timedelta(minutes=90),
|
||||
last_traced=utcnow() - timedelta(minutes=2),
|
||||
)
|
||||
# The traced age ("2m") renders left of the heard age ("1h") in the shown lane.
|
||||
traced_lane = _lane(row, 10, 1, 12, True).plain
|
||||
assert traced_lane.index("2m") < traced_lane.index("1h")
|
||||
# With the lane off, only the heard age shows.
|
||||
assert "2m" not in _lane(row, 10, 1, 12, False).plain
|
||||
|
||||
|
||||
def test_traced_sort_orders_most_recent_first_never_last() -> None:
|
||||
"""The traced sort opens most-recently-traced first, never-traced gathered at the bottom."""
|
||||
from meshterm.ui.contactlist import (
|
||||
TRACE_SORT_COLUMNS,
|
||||
TRACE_SORT_OPENS_ASCENDING,
|
||||
ContactRow,
|
||||
_ordered,
|
||||
)
|
||||
from meshterm.ui.widgets import ContactsSort
|
||||
|
||||
now = utcnow()
|
||||
rows = [
|
||||
ContactRow(value="stale", name="Stale", last_traced=now - timedelta(hours=5)),
|
||||
ContactRow(value="never", name="Never", last_traced=None),
|
||||
ContactRow(value="fresh", name="Fresh", last_traced=now - timedelta(minutes=1)),
|
||||
]
|
||||
sort = ContactsSort.from_name("traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING)
|
||||
assert sort.column == "traced" and sort.ascending is False # opens descending
|
||||
# Descending (the default): freshest trace on top, never-traced last.
|
||||
assert [r.value for r in _ordered(rows, sort)] == ["fresh", "stale", "never"]
|
||||
# Ascending flips the traced order but keeps never-traced out of the fresh block.
|
||||
sort.ascending = True
|
||||
assert [r.value for r in _ordered(rows, sort)] == ["never", "stale", "fresh"]
|
||||
|
||||
|
||||
def _picker(listed, *, prefix_bytes=0, sort=None, type_of=None, resolve_key=None, width=80):
|
||||
"""Build a rendered picker over ``listed`` and return it (rows sized to ``width``)."""
|
||||
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
|
||||
|
||||
Reference in new issue
Block a user