Dashboard traffic: the packet bucket breaks out by payload class
The lone "packet" meter lumped every overheard raw frame together; the traffic panel now meters each parsed payload class on its own row — channel text, trace, path, request, … — glossed and iconed from the packet viewer's shared tables, with only class-less frames left as a bare "packet". The composite packet:<TYPENAME> bucket shape runs through the stored seed (Repository.kind_counts) and the live tally (MonitorService) alike; an overheard ADVERT/ACK frame reads "raw advert"/"raw ack" so no two meters share a label. Raw-class meters stay muted — colour keeps marking the decoded families. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
79410c4f16
commit
dcc98f9cd8
4 files changed
+143
-26
No files matched your search
@@ -1376,23 +1376,30 @@ class Repository:
|
||||
|
||||
The persistent seed of the dashboard's traffic panel: what the recorder has
|
||||
heard across sessions, by kind, so the panel opens populated instead of
|
||||
counting from zero every launch. Messages and acks are separate event
|
||||
families (not observations) and are counted live on top of this.
|
||||
counting from zero every launch. A raw ``packet`` frame carrying a parsed
|
||||
payload class buckets as ``packet:<TYPENAME>`` (``packet:GRP_TXT``,
|
||||
``packet:TRACE``, …) so the panel can name what the frames were; only a
|
||||
class-less frame stays a bare ``packet``. Messages and acks are separate
|
||||
event families (not observations) and are counted live on top of this.
|
||||
|
||||
Args:
|
||||
since: Only observations at or after this time, if given.
|
||||
|
||||
Returns:
|
||||
``kind → count`` for every packet class ever stored (in the window).
|
||||
``bucket → count`` for every packet class ever stored (in the window).
|
||||
"""
|
||||
sql = "SELECT kind, COUNT(*) AS n FROM observations"
|
||||
sql = (
|
||||
"SELECT CASE WHEN kind = 'packet' AND payload_typename IS NOT NULL "
|
||||
"THEN 'packet:' || payload_typename ELSE kind END AS bucket, "
|
||||
"COUNT(*) AS n FROM observations"
|
||||
)
|
||||
params: list[Any] = []
|
||||
if since is not None:
|
||||
sql += " WHERE observed_at >= ?"
|
||||
params.append(since.isoformat())
|
||||
sql += " GROUP BY kind"
|
||||
sql += " GROUP BY bucket"
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return {row["kind"]: row["n"] for row in rows}
|
||||
return {row["bucket"]: row["n"] for row in rows}
|
||||
|
||||
def prune_observations(self, older_than: datetime) -> int:
|
||||
"""Delete observations that aged past the retention window (housekeeping).
|
||||
|
||||
@@ -149,12 +149,15 @@ class MonitorService:
|
||||
return tuple(bucket - i >= self._start_bucket for i in range(ACTIVITY_BUCKETS))
|
||||
|
||||
def kind_counts(self) -> dict[str, int]:
|
||||
"""Tallies by packet class: advert/telemetry/packet, message, ack.
|
||||
"""Tallies by packet class: advert/telemetry/packet buckets, message, ack.
|
||||
|
||||
Seeded from stored history at session start and grown live from there, so the
|
||||
numbers describe everything the recorder retains, not just this session.
|
||||
Observation classes come from the packet itself (``Observation.kind``);
|
||||
messages and acks are their own classes. A copy, safe to mutate.
|
||||
Observation classes come from the packet itself (``Observation.kind``) — a raw
|
||||
``packet`` frame with a parsed payload class buckets as ``packet:<TYPENAME>``,
|
||||
matching :meth:`~meshterm.persistence.repository.Repository.kind_counts`'s
|
||||
stored seed — and messages and acks are their own classes. A copy, safe to
|
||||
mutate.
|
||||
"""
|
||||
return dict(self._kind_counts)
|
||||
|
||||
@@ -164,6 +167,11 @@ class MonitorService:
|
||||
self._activity[bucket] = self._activity.get(bucket, 0) + 1
|
||||
obs = event.observation
|
||||
kind = obs.kind if obs is not None else event.kind.value
|
||||
if obs is not None and kind == "packet":
|
||||
# Bucket a classed raw frame by its payload class (the stored seed's shape).
|
||||
typename = (obs.raw or {}).get("payload_typename")
|
||||
if typename:
|
||||
kind = f"packet:{typename}"
|
||||
self._kind_counts[kind] = self._kind_counts.get(kind, 0) + 1
|
||||
if len(self._activity) > ACTIVITY_BUCKETS + 1:
|
||||
cutoff = bucket - ACTIVITY_BUCKETS
|
||||
|
||||
@@ -11,8 +11,10 @@ stacks three reads of the mesh, coarsest first:
|
||||
drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService`
|
||||
buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest
|
||||
node of the window.
|
||||
* **Traffic** — the session's tallies by packet class, each with a proportional bar,
|
||||
from the monitor's kind counters.
|
||||
* **Traffic** — the session's tallies by packet class, each with its icon and a
|
||||
proportional bar, from the monitor's kind counters — the raw ``packet`` bucket
|
||||
broken out by payload class (channel text, trace, path, …), so every known frame
|
||||
class shows once heard.
|
||||
* **RF health** — the trailing window's reception quality: median SNR (on the trace
|
||||
tool's quality bar) and RSSI from stored observations, plus the radio's own live
|
||||
numbers — noise floor, last RSSI/SNR, airtime, battery — polled from the device the
|
||||
@@ -42,7 +44,13 @@ from ..core.events import MeshEvent
|
||||
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
|
||||
from ..persistence.repository import OBSERVATION_WINDOW
|
||||
from .braillechart import axis_chart, meter, timeline_rows
|
||||
from .packet_viewer import KIND_STYLES
|
||||
from .packet_viewer import (
|
||||
DEFAULT_ICON,
|
||||
KIND_ICONS,
|
||||
KIND_STYLES,
|
||||
PAYLOAD_ICONS,
|
||||
_PAYLOAD_GLOSS,
|
||||
)
|
||||
from .theme import snr_style
|
||||
from .trace_screen import snr_bar
|
||||
from .tui.render import render_lines
|
||||
@@ -60,8 +68,38 @@ _STATS_POLL_S = 10.0
|
||||
#: How many braille rows tall the activity chart draws (each row is four dot rows).
|
||||
_CHART_ROWS = 3
|
||||
|
||||
#: The order the traffic panel lists packet classes in (heard ones not listed sort last).
|
||||
_KIND_ORDER = ("advert", "telemetry", "packet", "message", "ack")
|
||||
#: The order the traffic panel lists its buckets in (heard ones not listed sort last,
|
||||
#: alphabetically): the decoded families first, then the raw ``packet:<TYPENAME>``
|
||||
#: classes expanding in the old lone-``packet`` slot — chat-ish frames before
|
||||
#: protocol-ish ones — with the class-less ``packet`` closing the raw block.
|
||||
_TRAFFIC_ORDER = (
|
||||
"advert", "telemetry",
|
||||
"packet:GRP_TXT", "packet:GRP_DATA", "packet:TEXT_MSG", "packet:REQ",
|
||||
"packet:RESPONSE", "packet:ANON_REQ", "packet:PATH", "packet:TRACE",
|
||||
"packet:ADVERT", "packet:ACK", "packet:MULTIPART", "packet:CONTROL", "packet",
|
||||
"message", "ack",
|
||||
)
|
||||
|
||||
#: Raw payload glosses that would read identically to a decoded family's row, retold
|
||||
#: so two meters never share a label (an overheard advert frame vs. the advert event).
|
||||
_RAW_GLOSS_CLASH = {"advert": "raw advert", "ack": "raw ack"}
|
||||
|
||||
|
||||
def _traffic_chrome(bucket: str) -> tuple[str, str, str]:
|
||||
"""One traffic bucket's ``(icon, label, meter style)``.
|
||||
|
||||
A decoded family keeps its kind icon, name, and colour; a ``packet:<TYPENAME>``
|
||||
bucket takes the payload class's icon and gloss over the calm muted ``packet``
|
||||
meter — colour keeps marking the decoded families, raw overheard stays quiet.
|
||||
"""
|
||||
if bucket.startswith("packet:"):
|
||||
typename = bucket.split(":", 1)[1]
|
||||
gloss = _PAYLOAD_GLOSS.get(typename, typename.lower())
|
||||
label = _RAW_GLOSS_CLASH.get(gloss, gloss)
|
||||
return (
|
||||
PAYLOAD_ICONS.get(typename, DEFAULT_ICON), label, KIND_STYLES["packet"],
|
||||
)
|
||||
return KIND_ICONS.get(bucket, DEFAULT_ICON), bucket, KIND_STYLES.get(bucket, "brand")
|
||||
|
||||
#: How many character cells a traffic lane's meter spans (48 half-step levels).
|
||||
_TRAFFIC_METER_CELLS = 24
|
||||
@@ -294,29 +332,32 @@ class DashboardScreen(Screen):
|
||||
# -- traffic --
|
||||
|
||||
def _traffic_section(self) -> list[RenderableType]:
|
||||
"""Tallies by packet class, each with a proportional braille meter."""
|
||||
"""Tallies by packet class, each with an icon and a proportional braille meter.
|
||||
|
||||
Every known class shows once heard — the raw ``packet`` bucket is broken out
|
||||
by payload class (channel text, trace, path, …) instead of lumping every
|
||||
overheard frame together; classes never heard stay hidden.
|
||||
"""
|
||||
heading = Text("Traffic", style="accent")
|
||||
heading.append(" · by packet class · stored history + live", style="muted")
|
||||
counts = self._kind_counts()
|
||||
if not counts:
|
||||
return [heading, Text("nothing heard yet", style="muted")]
|
||||
order = {k: i for i, k in enumerate(_KIND_ORDER)}
|
||||
order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)}
|
||||
peak = max(counts.values())
|
||||
label_w = max(len(k) for k in counts)
|
||||
chrome = {bucket: _traffic_chrome(bucket) for bucket in counts}
|
||||
label_w = max(len(label) for _, label, _ in chrome.values())
|
||||
count_w = len(str(peak))
|
||||
rows: list[RenderableType] = [heading]
|
||||
for kind in sorted(counts, key=lambda k: (order.get(k, len(order)), k)):
|
||||
count = counts[kind]
|
||||
row = Text(f"{kind.ljust(label_w)} ", style="muted")
|
||||
for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)):
|
||||
count = counts[bucket]
|
||||
icon, label, style = chrome[bucket]
|
||||
row = Text(f"{icon} ")
|
||||
row.append(f"{label.ljust(label_w)} ", style="muted")
|
||||
row.append(f"{count:>{count_w}} ")
|
||||
# The shared braille meter, full-height with no track: the lane resolves
|
||||
# 48 levels across its 24 cells (two half-steps per cell).
|
||||
row.append_text(
|
||||
meter(
|
||||
count / peak, _TRAFFIC_METER_CELLS,
|
||||
style=KIND_STYLES.get(kind, "brand"),
|
||||
)
|
||||
)
|
||||
row.append_text(meter(count / peak, _TRAFFIC_METER_CELLS, style=style))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -125,6 +125,31 @@ def test_dashboard_pulse_drops_heard_only_when_it_wont_fit() -> None:
|
||||
assert "heard" not in tight and "nodes" in tight
|
||||
|
||||
|
||||
def test_dashboard_traffic_breaks_packets_out_by_payload_class() -> None:
|
||||
"""The raw ``packet`` bucket meters per payload class, glossed and iconed."""
|
||||
screen = _screen(
|
||||
window=[_obs()],
|
||||
kinds={"advert": 4, "packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1},
|
||||
)
|
||||
body = _plain(_stripped(screen.render_body(100)))
|
||||
assert "channel text" in body and "trace" in body # glossed, not raw typenames
|
||||
assert "GRP_TXT" not in body
|
||||
# The class-less remainder keeps a plain "packet" row alongside the classed ones.
|
||||
assert "packet" in body
|
||||
# Decoded families before the raw classes, class-less packet closing the block.
|
||||
assert body.index("advert") < body.index("channel text") < body.index("trace")
|
||||
|
||||
|
||||
def test_dashboard_traffic_disambiguates_raw_advert_and_ack_rows() -> None:
|
||||
"""An overheard ADVERT/ACK frame's row reads "raw …" so no two meters match."""
|
||||
screen = _screen(
|
||||
window=[_obs()],
|
||||
kinds={"advert": 4, "packet:ADVERT": 2, "ack": 3, "packet:ACK": 1},
|
||||
)
|
||||
body = _plain(_stripped(screen.render_body(100)))
|
||||
assert "raw advert" in body and "raw ack" in body
|
||||
|
||||
|
||||
def test_dashboard_live_observations_land_in_the_window() -> None:
|
||||
"""A hub observation folds into the trailing window and repaints the charts."""
|
||||
screen = _screen()
|
||||
@@ -156,6 +181,42 @@ def test_dashboard_radio_rows_render_device_stats() -> None:
|
||||
assert "4.01 V" in body
|
||||
|
||||
|
||||
def test_repository_kind_counts_bucket_packets_by_payload_class(tmp_path: Path) -> None:
|
||||
"""Stored packet frames with a payload class seed as ``packet:<TYPENAME>`` buckets."""
|
||||
repo = Repository(tmp_path / "kinds.db")
|
||||
run = repo.start_run("monitor", {}, None)
|
||||
repo.record_observation(run, _obs(node="a", kind="advert"))
|
||||
repo.record_observation(
|
||||
run, _obs(node="", kind="packet", raw={"payload_typename": "GRP_TXT"})
|
||||
)
|
||||
repo.record_observation(
|
||||
run, _obs(node="", kind="packet", raw={"payload_typename": "GRP_TXT"})
|
||||
)
|
||||
repo.record_observation(run, _obs(node="", kind="packet")) # class-less frame
|
||||
|
||||
counts = repo.kind_counts()
|
||||
assert counts["advert"] == 1
|
||||
assert counts["packet:GRP_TXT"] == 2
|
||||
assert counts["packet"] == 1 # only the class-less frame stays a bare packet
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_monitor_tallies_live_packets_by_payload_class(tmp_path: Path) -> None:
|
||||
"""A live classed packet observation lands in its ``packet:<TYPENAME>`` bucket."""
|
||||
from meshterm.services.monitor_service import MonitorService
|
||||
|
||||
monitor = MonitorService.__new__(MonitorService)
|
||||
monitor._activity = {}
|
||||
monitor._kind_counts = {}
|
||||
monitor._count_packet(
|
||||
MeshEvent.observation_event(
|
||||
_obs(node="", kind="packet", raw={"payload_typename": "TRACE"})
|
||||
)
|
||||
)
|
||||
monitor._count_packet(MeshEvent.observation_event(_obs(kind="advert")))
|
||||
assert monitor._kind_counts == {"packet:TRACE": 1, "advert": 1}
|
||||
|
||||
|
||||
# --- the repository window -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user