diff --git a/alembic/versions/20260613_0730_a1b2c3d4e5f6_add_packet_hash_received_at_index.py b/alembic/versions/20260613_0730_c3d4e5f6a7b8_add_packet_hash_received_at_index.py similarity index 84% rename from alembic/versions/20260613_0730_a1b2c3d4e5f6_add_packet_hash_received_at_index.py rename to alembic/versions/20260613_0730_c3d4e5f6a7b8_add_packet_hash_received_at_index.py index 6794f94..937b8ce 100644 --- a/alembic/versions/20260613_0730_a1b2c3d4e5f6_add_packet_hash_received_at_index.py +++ b/alembic/versions/20260613_0730_c3d4e5f6a7b8_add_packet_hash_received_at_index.py @@ -1,7 +1,7 @@ """add packet_hash_received_at composite index -Revision ID: a1b2c3d4e5f6 -Revises: e9f0c4079540 +Revision ID: c3d4e5f6a7b8 +Revises: e8eb47c49062 Create Date: 2026-06-13 07:30:00.000000+00:00 """ @@ -10,8 +10,8 @@ from typing import Sequence, Union from alembic import op -revision: str = "a1b2c3d4e5f6" -down_revision: Union[str, None] = "e9f0c4079540" +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "e8eb47c49062" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/src/meshcore_hub/api/routes/packet_groups.py b/src/meshcore_hub/api/routes/packet_groups.py index 5d90ce2..35ec2dc 100644 --- a/src/meshcore_hub/api/routes/packet_groups.py +++ b/src/meshcore_hub/api/routes/packet_groups.py @@ -34,9 +34,17 @@ def _group_key_builder(request: Request) -> str: def _extract_path_hashes(decoded: dict[str, Any] | None) -> list[str] | None: - """Extract pathHashes from decoded.payload.decoded.pathHashes.""" + """Extract the routing path (per-hop node hash bytes) from a decoded packet. + + The path lives at the top level as ``decoded.path`` for normal packets + (flood/advertisement/etc.). Trace-style packets instead carry it at + ``decoded.payload.decoded.pathHashes``, so that is used as a fallback. + """ if not decoded: return None + path = decoded.get("path") + if isinstance(path, list) and path: + return path payload = decoded.get("payload") or {} inner = payload.get("decoded") or {} hashes = inner.get("pathHashes") diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 2719ad2..eb3b309 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -108,6 +108,9 @@ def _build_endpoint_access( "v1/packets": { "GET": _OPEN, }, + "v1/packet-groups": { + "GET": _OPEN, + }, "v1/dashboard": { "GET": _OPEN, }, diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js index c68b34d..f29b650 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js @@ -12,9 +12,45 @@ function field(label, value) { `; } +// Centre-truncation thresholds for long paths. Counts hops, not characters, +// so variable-length hashes (1–3 bytes) truncate predictably. +const PATH_MAX_BADGES = 16; +const PATH_HEAD = 7; +const PATH_TAIL = 7; + +// Render a single path-hash as a badge. The `path-hash-badge` class and +// `data-path-hash` attribute are hooks for a future on-hover JSON lookup of +// candidate nodes matching the hash (not wired up yet). +function pathBadge(hash) { + return html`${hash}`; +} + +const pathArrow = html`→`; + +// Join badges with arrow separators into a flex-wrap container so long paths +// wrap onto multiple lines (growing in height, not width) on narrow screens. +function pathRow(badges) { + const parts = []; + badges.forEach((b, i) => { + if (i > 0) parts.push(pathArrow); + parts.push(b); + }); + return html`${parts}`; +} + function formatPath(pathHashes, pathLen) { if (pathHashes && pathHashes.length > 0) { - return html`${pathHashes.join(' → ')}`; + if (pathHashes.length <= PATH_MAX_BADGES) { + return pathRow(pathHashes.map(pathBadge)); + } + const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL; + const ellipsis = html``; + const badges = [ + ...pathHashes.slice(0, PATH_HEAD).map(pathBadge), + ellipsis, + ...pathHashes.slice(-PATH_TAIL).map(pathBadge), + ]; + return pathRow(badges); } if (pathLen != null) { return html`${pathLen} ${t('common.hops').toLowerCase()}`; @@ -136,7 +172,7 @@ ${content}`, container); ${recs.map(r => html` - ${formatPath(r.path_hashes, r.path_len)} + ${formatPath(r.path_hashes, r.path_len)} ${r.path_len != null ? r.path_len : '—'} ${r.snr != null ? Number(r.snr).toFixed(1) : '—'} diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 3b68532..0ec6602 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -233,6 +233,7 @@ "reception_plural": "paths", "col_path": "Path", "col_receptions": "Receptions", + "hops_hidden": "{{count}} hops hidden", "sort": { "newest": "Time (newest)", "oldest": "Time (oldest)", diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 4ab8120..1fe53ef 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -173,6 +173,7 @@ "col_source": "Bron", "col_route_type": "Routetype", "col_raw": "Ruw", + "hops_hidden": "{{count}} hops verborgen", "sort": { "newest": "Tijd (nieuwste)", "oldest": "Tijd (oudste)", diff --git a/tests/test_api/test_packet_groups.py b/tests/test_api/test_packet_groups.py index 85cbcba..e863090 100644 --- a/tests/test_api/test_packet_groups.py +++ b/tests/test_api/test_packet_groups.py @@ -524,6 +524,28 @@ class TestExtractPathHashes: decoded = {"payload": {"decoded": {"pathHashes": ["AA", "BB"]}}} assert _extract_path_hashes(decoded) == ["AA", "BB"] + def test_extracts_top_level_path(self): + from meshcore_hub.api.routes.packet_groups import _extract_path_hashes + + # Normal (flood/advertisement) packets carry the routing path here. + decoded = {"path": ["16", "69", "23"], "pathLength": 3} + assert _extract_path_hashes(decoded) == ["16", "69", "23"] + + def test_top_level_path_takes_precedence(self): + from meshcore_hub.api.routes.packet_groups import _extract_path_hashes + + decoded = { + "path": ["16", "69"], + "payload": {"decoded": {"pathHashes": ["AA"]}}, + } + assert _extract_path_hashes(decoded) == ["16", "69"] + + def test_empty_top_level_path_falls_back(self): + from meshcore_hub.api.routes.packet_groups import _extract_path_hashes + + decoded = {"path": [], "payload": {"decoded": {"pathHashes": ["AA"]}}} + assert _extract_path_hashes(decoded) == ["AA"] + def test_none_input(self): from meshcore_hub.api.routes.packet_groups import _extract_path_hashes diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py index fec26b3..b733738 100644 --- a/tests/test_web/test_app.py +++ b/tests/test_web/test_app.py @@ -10,6 +10,7 @@ from meshcore_hub.web.app import ( _AUTHENTICATED, _OPEN, _build_config_json, + _build_endpoint_access, check_api_access, create_app, ) @@ -249,6 +250,21 @@ class TestCheckApiAccess: mapping=mapping, ) + def test_built_mapping_allows_anonymous_packet_groups(self) -> None: + """The real mapping exposes packet-groups as open (regression for 403). + + ``v1/packet-groups`` is not a prefix of ``v1/packets`` ("packets" vs + "packet-"), so it needs its own entry or the proxy denies it. + """ + mapping = _build_endpoint_access(role_admin="admin") + assert check_api_access( + "v1/packet-groups", "GET", False, frozenset(), mapping=mapping + ) + # Detail route is covered by prefix match. + assert check_api_access( + "v1/packet-groups/abc123", "GET", False, frozenset(), mapping=mapping + ) + class TestRadioConfigSettingsFallback: """Tests that radio config falls back to settings when params are None."""