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);