Show node hashes in trace tables and info panel

Annotate our own device with its hash on the route line, the per-trace
From→To table, and the per-hop medians table, truncated to the command's
path-hash width like every other hop. Unify link rendering on
_route_node_text so all three reads consistently as name (hash).

In the info panel, replace the standalone node-hash line with a Nodes
table: our node listed first with its name highlighted, and every node's
full public key shown with its path-hash prefix highlighted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-06-21 12:26:09 -04:00
1 parent 421781f13d
commit 89e1076a06
5 files changed
+227 -47

No files matched your search

+8 -8
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
import typer
from rich.table import Table
from ..context import AppContext
from .base import Tool, ToolResult, register
@@ -32,6 +31,7 @@ class InfoTool(Tool):
"""
from ..core.device_config import build_snapshot
from ..ui.config_editor import render_config
from ..ui.widgets import nodes_table
device = await ctx.device()
snapshot = await build_snapshot(device)
@@ -41,13 +41,13 @@ class InfoTool(Tool):
# Render every current setting with a short explanation of each.
render_config(ctx.console, snapshot, custom)
if contacts:
ctable = Table(title=f"Contacts ({len(contacts)})", border_style="muted")
ctable.add_column("Name", style="brand")
ctable.add_column("Key prefix", style="muted")
for c in contacts:
ctable.add_row(c.name, c.key_prefix or "[muted]?[/muted]")
ctx.console.print(ctable)
# List our own node (first, highlighted) and contacts, each with its full key and
# the path-hash prefix — the slice a forced path addresses — highlighted.
mode = snapshot.get("path_hash_mode")
prefix_bytes = (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
self_name = str(snapshot.get("name") or "this node")
self_key = str(snapshot.get("public_key") or "")
ctx.console.print(nodes_table(self_name, self_key, contacts, prefix_bytes))
return ToolResult(
summary={"name": snapshot.get("name"), "contacts": len(contacts)},
+7 -2
View File
@@ -103,6 +103,9 @@ class TraceTool(Tool):
# last hop's destination); fall back to a neutral label if it's unavailable.
self_info = await device.get_self_info()
device_label = str(self_info.get("name") or LOCAL_DEVICE_LABEL)
# Our own public key, so the route's endpoints (us) carry a hash like every
# other hop, addressed at the same path-hash width.
device_hash = str(self_info.get("public_key") or "") or None
# Resolve repeater hashes in the results to contact names where we know them,
# so the tables read as names instead of opaque hex prefixes.
@@ -142,8 +145,10 @@ class TraceTool(Tool):
# (forced path, or the route the device resolved when auto-routing).
current = next((t for t in traces if t.success), None)
if traces:
ctx.console.print(traces_table(traces, device_label, resolve))
ctx.console.print(stats_panel(stats, device_label, resolve, route=current))
ctx.console.print(traces_table(traces, device_label, resolve, device_hash))
ctx.console.print(
stats_panel(stats, device_label, resolve, route=current, device_hash=device_hash)
)
summary: dict[str, Any] = {
"target": stats.target,
+142 -37
View File
@@ -20,6 +20,7 @@ from rich.text import Text
from .. import __version__
from ..core.models import (
LOCAL_DEVICE_LABEL,
Contact,
HopAggregate,
HopEdge,
TraceResult,
@@ -93,45 +94,37 @@ def make_progress(console: Console) -> Progress:
)
def _node_text(
label: Optional[str], device_label: str, resolve: NodeResolver = _identity
) -> Text:
"""Style a node label, making our own device stand out from repeaters.
Args:
label: The node identifier to render; ``None`` means our own device.
device_label: The label used for our own device.
resolve: Maps a raw hop hash to a friendly name when the node is known.
Returns:
A styled :class:`Text` for the node.
"""
named = resolve(label) if label else label
resolved = named if named else device_label
style = "accent" if resolved == device_label else "brand"
return Text(resolved, style=style)
def _link_text(
origin: Optional[str],
destination: Optional[str],
device_label: str,
resolve: NodeResolver = _identity,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
) -> Text:
"""Render an ``origin -> destination`` link with a styled arrow.
"""Render an ``origin -> destination`` link as ``name (hash)`` nodes, arrow styled.
Each end is rendered like the route line: a known node as ``name (hash)``, an
unknown one as its bare hash, and our own device as its label plus ``device_hash``
when supplied. Hashes are truncated to ``hash_bytes`` so they match how the trace
command addressed each node.
Args:
origin: The transmitting node (``None`` = our device).
destination: The receiving node (``None`` = our device).
device_label: The label used for our own device.
resolve: Maps a raw hop hash to a friendly name when the node is known.
hash_bytes: Path-hash width (bytes) to truncate shown hashes to.
device_hash: Our own device's key/hash, shown alongside its label when known.
Returns:
A :class:`Text` like ``us → Alice`` with the arrow muted.
A :class:`Text` like ``us (a1) → Alice (3d)`` with the arrow muted.
"""
text = _node_text(origin, device_label, resolve)
text = _route_node_text(origin, device_label, resolve, hash_bytes, device_hash)
text.append(" → ", style="muted")
text.append_text(_node_text(destination, device_label, resolve))
text.append_text(
_route_node_text(destination, device_label, resolve, hash_bytes, device_hash)
)
return text
@@ -139,24 +132,30 @@ def traces_table(
traces: list[TraceResult],
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
device_hash: Optional[str] = None,
) -> Table:
"""Render every trace's per-hop SNR side by side, one column per trace.
Hops are framed as ``origin -> destination`` links (the first originates at our
device, the last returns to it) and aligned by position across traces, so each
column is one trace's SNR readings down the shared path. A trailing ``min`` row
shows each trace's bottleneck — the per-trace values the run's *median min SNR*
summarizes. Traces that never replied appear as a ``✗`` column.
column is one trace's SNR readings down the shared path. Each node is annotated with
its hash at the command's path-hash width. A trailing ``min`` row shows each trace's
bottleneck — the per-trace values the run's *median min SNR* summarizes. Traces that
never replied appear as a ``✗`` column.
Args:
traces: The individual traces to display, in run order.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
Returns:
A Rich :class:`Table` with a column per trace.
"""
target = traces[0].target if traces else ""
# All traces in a run share the command's path-hash width; take it from the first
# successful one so node hashes render at the width they were addressed.
hash_bytes = next((t.path_hash_bytes for t in traces if t.success), None)
table = Table(title=f"Trace → {target}", border_style="muted", expand=False)
table.add_column("Hop", justify="right", style="muted")
table.add_column("From → To")
@@ -172,7 +171,10 @@ def traces_table(
for idx in indices:
link = next(
(
_link_text(edges[idx].origin, edges[idx].destination, device_label, resolve)
_link_text(
edges[idx].origin, edges[idx].destination, device_label, resolve,
hash_bytes, device_hash,
)
for edges in edges_by_trace
if idx in edges
),
@@ -201,11 +203,52 @@ def traces_table(
return table
def highlighted_hash(value: str, prefix_bytes: int) -> Text:
"""Render a full hex key with its leading path-hash prefix highlighted.
The first ``prefix_bytes`` bytes are the slice other nodes address in a forced trace
path (the path-hash); they are shown in the brand colour and the remainder muted, so
the addressable prefix stands out within the otherwise full key.
Args:
value: The full key as hex, optionally ``0x``-prefixed and mixed-case.
prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0``
(or negative) leaves the whole key un-highlighted.
Returns:
A styled :class:`Text` of the full key.
"""
raw = value.lower().removeprefix("0x")
split = max(0, prefix_bytes) * 2
text = Text()
if split:
text.append(raw[:split], style="brand")
text.append(raw[split:], style="muted")
else:
text.append(raw, style="muted")
return text
def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str:
"""Return ``value`` as bare hex truncated to ``hash_bytes`` bytes.
Args:
value: A hex hash, optionally ``0x``-prefixed and mixed-case.
hash_bytes: Width in bytes to truncate to; the full value when falsy/unknown.
Returns:
Lowercase hex with no ``0x`` prefix, at most ``hash_bytes`` bytes wide.
"""
raw = value.lower().removeprefix("0x")
return raw[: hash_bytes * 2] if hash_bytes else raw
def _route_node_text(
label: Optional[str],
device_label: str,
resolve: NodeResolver,
hash_bytes: Optional[int],
device_hash: Optional[str] = None,
) -> Text:
"""Render one route node as ``name (hash)``, the hash at the command's width.
@@ -219,15 +262,21 @@ def _route_node_text(
device_label: The label used for our own device.
resolve: Maps a raw hop hash to a friendly contact name when known.
hash_bytes: Path-hash width (bytes) to truncate the shown hash to.
device_hash: Our own device's key/hash, shown alongside its label when known.
Returns:
A styled :class:`Text` for the node. A non-breaking space joins name and hash
so they never split across a line wrap.
"""
if not label or label == device_label:
return Text(device_label, style="accent")
raw = label.lower().removeprefix("0x")
shown = raw[: hash_bytes * 2] if hash_bytes else raw
text = Text(device_label, style="accent")
shown = _shorten_hash(device_hash, hash_bytes) if device_hash else ""
if shown:
text.append(f"{_NBSP}(", style="muted") # nbsp keeps "name (hash)" together
text.append(shown, style="muted")
text.append(")", style="muted")
return text
shown = _shorten_hash(label, hash_bytes)
named = resolve(label)
if not named or named == label:
return Text(shown, style="brand") # unknown node: hash only
@@ -242,19 +291,21 @@ def _route_text(
result: TraceResult,
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
device_hash: Optional[str] = None,
) -> Text:
"""Render a trace's route as a sequence of ``name (hash)`` nodes.
Shows the path the trace actually walked — the forced path, or the route the
device resolved when auto-routing — with each node annotated by its hash at the
command's path-hash width, joined by muted arrows, e.g.
``Me → Alice (3d63) → Bob (f2a1) → Me``. The line wraps after an arrow when it is
too long for the panel, so each continuation line starts on a node.
``Me (a1b2) → Alice (3d63) → Bob (f2a1) → Me (a1b2)``. The line wraps after an arrow
when it is too long for the panel, so each continuation line starts on a node.
Args:
result: The trace whose route to display.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
device_hash: Our own device's key/hash, annotated onto its endpoints when known.
Returns:
A :class:`Text` with the node sequence, or a muted note when no hops exist.
@@ -270,12 +321,18 @@ def _route_text(
# A non-breaking space glues the arrow to the preceding node; the trailing
# regular space is the only wrap point, so wrapping lands *after* the arrow.
text.append(f"{_NBSP}→ ", style="muted")
text.append_text(_route_node_text(node, device_label, resolve, hash_bytes))
text.append_text(
_route_node_text(node, device_label, resolve, hash_bytes, device_hash)
)
return text
def _hop_medians_table(
hop_snrs: list[HopAggregate], device_label: str, resolve: NodeResolver = _identity
hop_snrs: list[HopAggregate],
device_label: str,
resolve: NodeResolver = _identity,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
) -> Table:
"""Render the per-hop median SNR aggregated across a run's traces.
@@ -283,6 +340,8 @@ def _hop_medians_table(
hop_snrs: The per-hop aggregates to display.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
hash_bytes: Path-hash width (bytes) to truncate shown node hashes to.
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
Returns:
A compact Rich :class:`Table` of hop, link, and median SNR.
@@ -294,7 +353,9 @@ def _hop_medians_table(
for agg in hop_snrs:
table.add_row(
str(agg.index),
_link_text(agg.origin, agg.destination, device_label, resolve),
_link_text(
agg.origin, agg.destination, device_label, resolve, hash_bytes, device_hash
),
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)),
)
return table
@@ -305,6 +366,7 @@ def stats_panel(
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
route: Optional[TraceResult] = None,
device_hash: Optional[str] = None,
) -> Panel:
"""Summarize aggregated trace statistics in a panel.
@@ -318,6 +380,7 @@ def stats_panel(
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
route: A representative trace whose walked route to display, if any.
device_hash: Our own device's key/hash, annotated onto the route endpoints.
Returns:
A Rich :class:`Panel` with the route, success rate, robust SNR/RTT, and
@@ -336,16 +399,58 @@ def stats_panel(
sections: list[Text | Table] = []
if route is not None:
sections.append(Text("route", style="muted"))
sections.append(_route_text(route, device_label, resolve))
sections.append(_route_text(route, device_label, resolve, device_hash))
sections.append(Text()) # blank line before the stats block
sections.append(summary)
if stats.hop_snrs:
sections.append(Text("\nper-hop medians", style="muted"))
sections.append(_hop_medians_table(stats.hop_snrs, device_label, resolve))
hash_bytes = route.path_hash_bytes if route is not None else None
sections.append(
_hop_medians_table(stats.hop_snrs, device_label, resolve, hash_bytes, device_hash)
)
body: Text | Group = sections[0] if len(sections) == 1 else Group(*sections)
return Panel(body, title="[accent]trace summary[/accent]", border_style="accent", expand=False)
def nodes_table(
self_name: str,
self_key: str,
contacts: list[Contact],
prefix_bytes: int,
) -> Table:
"""List this node and its known contacts, full keys with the path-hash prefix lit.
Our own node is the first row, its name highlighted to set it apart. Every node's key
is shown in full with the leading ``prefix_bytes`` bytes — the slice a forced trace
path addresses at the current path-hash mode — highlighted.
Args:
self_name: This node's advertised name.
self_key: This node's full public key (hex); blank renders as ``?``.
contacts: Known contacts, listed after our own node.
prefix_bytes: Path-hash width in bytes to highlight in every key.
Returns:
A Rich :class:`Table` of name and full public key.
"""
table = Table(title=f"Nodes ({len(contacts)} contacts)", border_style="muted")
table.add_column("Name")
table.add_column("Public key [muted](path-hash prefix highlighted)[/muted]")
unknown = Text("?", style="muted")
table.add_row(
Text(f"{self_name} (this node)", style="accent"), # our node, name highlighted
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
)
table.add_section()
for c in contacts:
table.add_row(
Text(c.name, style="brand"),
highlighted_hash(c.public_key, prefix_bytes) if c.public_key else unknown,
)
return table
def tx_opt_table(result: TxOptResult) -> Table:
"""Render every measured TX level of an optimization sweep, best row highlighted.
+29
View File
@@ -69,6 +69,35 @@ def test_path_hash_mode_is_strict_enum() -> None:
parse_value(get_spec("path_hash_mode"), "4")
def test_highlighted_hash_highlights_path_hash_prefix() -> None:
"""The full key is shown with only its path-hash prefix bytes highlighted."""
from meshtools.ui.widgets import highlighted_hash
pub = "aabbccddee" + "00" * 27
# mode 2 => 3-byte hashes => first 6 hex chars are the addressable prefix.
text = highlighted_hash(pub, prefix_bytes=3)
assert text.plain == pub # the full key is shown
highlighted = [s for s in text.spans if s.style == "brand"]
assert len(highlighted) == 1
assert text.plain[highlighted[0].start : highlighted[0].end] == "aabbcc"
def test_nodes_table_lists_us_first_with_full_keys() -> None:
"""The nodes table puts our node first (name highlighted) with full keys."""
from meshtools.core.models import Contact
from meshtools.ui.widgets import nodes_table
contacts = [Contact(name="Alice", public_key="3d63c6" + "00" * 29, key_prefix="3d63c6")]
table = nodes_table("Homestead", "aabbcc" + "00" * 29, contacts, prefix_bytes=3)
# Two body rows: our node, then Alice.
names = [cell.plain if hasattr(cell, "plain") else str(cell) for cell in table.columns[0].cells]
assert names[0].startswith("Homestead")
assert "Alice" in names[1]
keys = list(table.columns[1].cells)
assert keys[0].plain == "aabbcc" + "00" * 29 # full key, not a short prefix
assert any(s.style == "brand" for s in keys[0].spans) # prefix highlighted
def test_non_strict_enum_accepts_unlisted_value() -> None:
"""multi_acks/adv_loc_policy list common values but still accept other ints."""
spec = get_spec("multi_acks")
+41
View File
@@ -118,6 +118,47 @@ def test_route_text_annotates_nodes_with_command_width_hash() -> None:
assert "Me (" not in plain.replace(" ", " ") # device never annotated
def test_route_text_annotates_our_device_with_hash() -> None:
"""Given our key, both endpoints (us) carry our hash at the command width."""
from meshtools.core.models import Hop
from meshtools.ui.widgets import _route_text
result = TraceResult(
target="x",
success=True,
hops=[Hop(0, "3d63", 12.0), Hop(1, None, 12.0)],
path_hash_bytes=2, # command used 2-byte hashes
)
plain = _route_text(
result, "Me", device_hash="a1b2c3" + "00" * 29
).plain.replace("\xa0", " ")
assert plain.count("Me (a1b2)") == 2 # our device at both ends, carrying our hash
assert "a1b2c3" not in plain # truncated to the command's 2-byte width
def test_traces_table_annotates_links_with_hashes() -> None:
"""The per-trace From→To column shows ``name (hash)``, including our device."""
from meshtools.core.models import Hop
from meshtools.ui.widgets import traces_table
resolve = trace_runner.make_node_resolver(
[Contact(name="Alice", public_key="3d63c6" + "00" * 26, key_prefix="3d63c6429436")]
)
traces = [
TraceResult(
target="Alice",
success=True,
hops=[Hop(0, "3d63c6", 12.0), Hop(1, None, 9.0)],
path_hash_bytes=2, # command used 2-byte hashes
)
]
table = traces_table(traces, "Me", resolve, device_hash="a1b2c3" + "00" * 29)
links = [cell.plain.replace("\xa0", " ") for cell in table.columns[1].cells]
assert any("Me (a1b2)" in link for link in links) # our device carries its hash
assert any("Alice (3d63)" in link for link in links) # truncated to the 2-byte width
assert not any("3d63c6" in link for link in links)
def test_route_text_unknown_node_shows_hash_only() -> None:
"""An unresolved node shows just its hash, truncated to the command width."""
from meshtools.core.models import Hop