From 87e7d7676be4a18398b0c57404cd84c0acd0b012 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sat, 13 Jun 2026 16:47:22 +0100 Subject: [PATCH] Link rows to packet-detail page with path-hash node lookup Adverts/Messages rows now link directly to the deduplicated packet-detail page (/packets/hash/:hash). Each path hop renders as a clickable badge opening a popover that looks up nodes by public-key prefix via the new pubkey_prefix query param on GET /api/v1/nodes (case-insensitive startswith). Adds a derived path_hash_bytes field on GroupedPacketRead. Defaults changed: FEATURE_PACKETS now defaults to true and RAW_PACKET_RETENTION_DAYS to 7 (independent of DATA_RETENTION_DAYS). Fixes a mypy arg-type error by explicitly annotating the packet_hash list as list[str]. --- .env.example | 14 +- AGENTS.md | 6 +- README.md | 4 + docker-compose.yml | 10 +- docs/upgrading.md | 23 +- src/meshcore_hub/api/routes/nodes.py | 8 + src/meshcore_hub/api/routes/packet_groups.py | 27 ++- src/meshcore_hub/collector/subscriber.py | 6 +- src/meshcore_hub/common/config.py | 7 +- .../common/schemas/raw_packets.py | 7 + .../web/static/js/spa/components.js | 68 ------ src/meshcore_hub/web/static/js/spa/icons.js | 8 + .../web/static/js/spa/pages/advertisements.js | 88 +++----- .../web/static/js/spa/pages/messages.js | 63 ++---- .../web/static/js/spa/pages/nodes.js | 6 +- .../js/spa/pages/packet-group-detail.js | 213 +++++++++++++++--- .../web/static/js/spa/pages/packets.js | 26 +-- src/meshcore_hub/web/static/locales/en.json | 6 + src/meshcore_hub/web/static/locales/nl.json | 6 + tests/test_api/test_nodes.py | 42 ++++ tests/test_api/test_packet_groups.py | 77 +++++++ tests/test_common/test_config.py | 8 +- tests/test_web/test_features.py | 6 +- 23 files changed, 472 insertions(+), 257 deletions(-) diff --git a/.env.example b/.env.example index 3255bd9..194e6f7 100644 --- a/.env.example +++ b/.env.example @@ -246,11 +246,11 @@ DATA_RETENTION_INTERVAL_HOURS=24 # setting FEATURE_PACKETS=true enables both capture and the Packets page. # RAW_PACKET_CAPTURE_ENABLED=false -# Days to retain raw packets. Defaults to DATA_RETENTION_DAYS when unset. -# The raw_packets table grows fastest of all; lower this on busy meshes or -# constrained storage. Retention runs regardless of capture being enabled, so -# disabling capture lets existing rows drain. -# RAW_PACKET_RETENTION_DAYS=30 +# Days to retain raw packets before cleanup. Defaults to 7, independent of +# DATA_RETENTION_DAYS. The raw_packets table grows fastest of all; lower this on +# busy meshes or constrained storage. Retention runs regardless of capture being +# enabled, so disabling capture lets existing rows drain. +# RAW_PACKET_RETENTION_DAYS=7 # ------------------- # Node Cleanup Settings @@ -511,9 +511,9 @@ NETWORK_ANNOUNCEMENT= # FEATURE_PAGES=true # FEATURE_CHANNELS=true # FEATURE_RADIO_CONFIG=true -# Packets page is OFF by default. Enabling it also drives raw-packet capture +# Packets page is ON by default. This var also drives raw-packet capture # on the collector via Compose (RAW_PACKET_CAPTURE_ENABLED=${FEATURE_PACKETS}). -# FEATURE_PACKETS=false +# FEATURE_PACKETS=true # ------------------- # Contact Information diff --git a/AGENTS.md b/AGENTS.md index b00afae..bc0d673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -681,7 +681,7 @@ Key variables: - `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable) - `WEB_DEBUG` - Enable debug mode in the web dashboard (default: `false`) - `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`) -- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES`, `FEATURE_CHANNELS`, `FEATURE_RADIO_CONFIG` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. `FEATURE_PACKETS` - enables the Packets page (default: `false`); in Compose it also drives `RAW_PACKET_CAPTURE_ENABLED` on the collector. +- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES`, `FEATURE_CHANNELS`, `FEATURE_RADIO_CONFIG` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. `FEATURE_PACKETS` - enables the Packets page (default: `true`); in Compose it also drives `RAW_PACKET_CAPTURE_ENABLED` on the collector. - `NETWORK_DOMAIN` - Network domain name (default: none) - `NETWORK_NAME` - Network display name (default: `MeshCore Network`) - `NETWORK_CITY` - Network city location (default: none) @@ -770,9 +770,9 @@ When enabled, the collector automatically deletes event data older than the rete | Variable | Description | |----------|-------------| | `RAW_PACKET_CAPTURE_ENABLED` | Capture every packets-feed packet into `raw_packets` (default: false). In Compose, derived from `FEATURE_PACKETS`. | -| `RAW_PACKET_RETENTION_DAYS` | Days to retain raw packets (default: falls back to `DATA_RETENTION_DAYS`). | +| `RAW_PACKET_RETENTION_DAYS` | Days to retain raw packets before cleanup (default: `7`, independent of `DATA_RETENTION_DAYS`). | -When enabled, the collector writes one `RawPacket` row per observer reception from the LetsMesh `packets` feed, reusing the decode the normalizer already performs (decoder per-hex cache). The `/packets` API serves these with channel-visibility redaction (restricted-channel packets returned metadata-only) and a role-aware Redis cache key. The web Packets page is gated behind `FEATURE_PACKETS` (default off). Raw-packet retention cleanup runs whenever data cleanup runs, regardless of the capture flag. +When enabled, the collector writes one `RawPacket` row per observer reception from the LetsMesh `packets` feed, reusing the decode the normalizer already performs (decoder per-hex cache). The `/packets` API serves these with channel-visibility redaction (restricted-channel packets returned metadata-only) and a role-aware Redis cache key. A companion `/api/v1/packet-groups` endpoint deduplicates by `packet_hash` (one row per transmission, with all per-observer receptions and routing paths) and backs the SPA packet-detail page at `/packets/hash/:hash`. The web Packets page is gated behind `FEATURE_PACKETS` (default on). The Adverts/Messages list rows link directly to that packet-detail page; path-hash badges there look up matching nodes via `GET /api/v1/nodes?pubkey_prefix=` (case-insensitive `startswith` on `public_key`). Raw-packet retention cleanup runs whenever data cleanup runs, regardless of the capture flag. **Node Cleanup:** diff --git a/README.md b/README.md index 195f73b..cc4b137 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ flowchart LR ## Features - **Event Persistence**: Store messages, advertisements, telemetry, and trace data +- **Raw Packet Inspection**: Capture, browse, and search raw wire packets; a deduplicated packet view shows every observer reception and routing path, with clickable path-hash badges that look up the matching nodes - **REST API**: Query historical data with filtering and pagination - **Node Tagging**: Add custom metadata to nodes for organization - **Web Dashboard**: Visualize network status, node locations, and message history @@ -374,6 +375,8 @@ The collector automatically cleans up old event data and inactive nodes: | `DATA_RETENTION_INTERVAL_HOURS` | `24` | Hours between cleanup runs | | `NODE_CLEANUP_ENABLED` | `true` | Enable removal of inactive nodes | | `NODE_CLEANUP_DAYS` | `30` | Remove nodes not seen for this many days | +| `RAW_PACKET_CAPTURE_ENABLED` | `false` | Capture raw packets into `raw_packets`. In Compose, derived from `FEATURE_PACKETS` | +| `RAW_PACKET_RETENTION_DAYS` | `7` | Days to retain raw packets (independent of `DATA_RETENTION_DAYS`) | ### API Settings @@ -478,6 +481,7 @@ Control which pages are visible in the web dashboard. Disabled features are full | `FEATURE_PAGES` | `true` | Enable custom markdown pages | | `FEATURE_CHANNELS` | `true` | Enable the `/channels` page | | `FEATURE_RADIO_CONFIG` | `true` | Show radio config panel on home page | +| `FEATURE_PACKETS` | `true` | Enable the `/packets` raw-packet browser. In Compose this also drives `RAW_PACKET_CAPTURE_ENABLED` on the collector | **Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. Members auto-disables when OIDC is disabled (set via `OIDC_ENABLED`). diff --git a/docker-compose.yml b/docker-compose.yml index f714755..998127c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -198,9 +198,9 @@ services: - NODE_CLEANUP_ENABLED=${NODE_CLEANUP_ENABLED:-true} - NODE_CLEANUP_DAYS=${NODE_CLEANUP_DAYS:-30} # Raw packet capture (derived from FEATURE_PACKETS so one var drives both - # capture and the web Packets page). Retention defaults to DATA_RETENTION_DAYS. - - RAW_PACKET_CAPTURE_ENABLED=${FEATURE_PACKETS:-false} - - RAW_PACKET_RETENTION_DAYS=${RAW_PACKET_RETENTION_DAYS:-${DATA_RETENTION_DAYS:-30}} + # capture and the web Packets page). Retention defaults to 7 days. + - RAW_PACKET_CAPTURE_ENABLED=${FEATURE_PACKETS:-true} + - RAW_PACKET_RETENTION_DAYS=${RAW_PACKET_RETENTION_DAYS:-7} command: ["collector"] healthcheck: test: ["CMD", "meshcore-hub", "health", "collector"] @@ -344,8 +344,8 @@ services: - FEATURE_PAGES=${FEATURE_PAGES:-true} - FEATURE_CHANNELS=${FEATURE_CHANNELS:-true} - FEATURE_RADIO_CONFIG=${FEATURE_RADIO_CONFIG:-true} - # Packets page is off by default; enabling it also turns on collector capture. - - FEATURE_PACKETS=${FEATURE_PACKETS:-false} + # Packets page is on by default; this also drives collector capture. + - FEATURE_PACKETS=${FEATURE_PACKETS:-true} command: ["web"] healthcheck: test: diff --git a/docs/upgrading.md b/docs/upgrading.md index 25ef689..08021f9 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -20,9 +20,9 @@ This creates the `raw_packets` table and its indexes. On Docker deployments the | Variable | Default | Description | | ---------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | -| `FEATURE_PACKETS` | `false` | Show the Packets page and nav entry. Off by default. | +| `FEATURE_PACKETS` | `true` | Show the Packets page and nav entry. On by default. | | `RAW_PACKET_CAPTURE_ENABLED` | `false` | Collector-side capture of raw packets. In Compose this is **derived from `FEATURE_PACKETS`**. | -| `RAW_PACKET_RETENTION_DAYS` | = `DATA_RETENTION_DAYS` | Days to retain raw packets, independent of the global retention window. | +| `RAW_PACKET_RETENTION_DAYS` | `7` | Days to retain raw packets, independent of the global retention window. | **Capture ↔ page split:** capture runs in the collector while the page is served by the web app — two separate processes with separate settings. Docker Compose links them: setting `FEATURE_PACKETS=true` enables **both** capture (`RAW_PACKET_CAPTURE_ENABLED=${FEATURE_PACKETS}` on the collector) and the page. Advanced operators running the processes directly can set the two flags independently. @@ -32,9 +32,9 @@ This creates the `raw_packets` table and its indexes. On Docker deployments the **Caching:** `/packets` responses are cached in Redis (when enabled) using a **role-aware** cache key and honour the existing `REDIS_CACHE_TTL`, so redacted responses are never served across roles. -The `advertisements` and `messages` tables gain a nullable `packet_hash` column (added by the same `db upgrade`) so each event can link to its captured raw packets. When `FEATURE_PACKETS` is on, the Adverts and Messages list pages show a packet icon linking to the raw packets for that transmission. Only events ingested while capture was enabled carry the hash (no backfill), so the link is hidden for older rows. +The `advertisements` and `messages` tables gain a nullable `packet_hash` column (added by the same `db upgrade`) so each event can link to its captured raw packets. When `FEATURE_PACKETS` is on, the entire Adverts/Messages list row links to that transmission's deduplicated packet-detail page (see below). Only events ingested while capture was enabled carry the hash (no backfill), so non-capturing rows are not clickable. -**No action required** to keep current behaviour — the feature is off by default. +**On by default:** as of v0.13.0 `FEATURE_PACKETS` defaults to `true` (was `false`). To keep the page hidden and capture off, set `FEATURE_PACKETS=false`. ### Finer-Grained Packet Classification @@ -42,6 +42,21 @@ Packets the collector previously could not categorise were all emitted as a sing **Action only if you consume `event_type`:** any external webhook filter, saved query, or dashboard keyed on `letsmesh_packet` should be updated to the specific type(s) it cares about. No database migration or config change is involved. +### Deduplicated Packet Detail & Node Path Lookup + +The Packets experience is now centred on a **deduplicated packet-detail page** (`/packets/hash/:hash`, backed by `GET /api/v1/packet-groups`): one entry per `packet_hash`, listing every observer reception with its SNR and full routing path. Each hop renders as a **path-hash badge**; clicking a badge opens a popover that looks up the node(s) whose public key starts with that 1–3 byte hex prefix (via the new `pubkey_prefix` query param on `GET /api/v1/nodes`) and links to each node's detail page, capped at 8 with a link through to the prefix-filtered Nodes page. + +Adverts and Messages list rows (desktop and mobile) now link **directly** to this packet-detail page instead of a filtered packet list. The old per-row packet icon, the inline observer-expansion row, and the `/packets` packet-hash filter chip have been removed; the observer-count column remains. + +**Defaults changed in this release:** + +- `FEATURE_PACKETS` now defaults to `true` (page on, and capture on in Compose). +- `RAW_PACKET_RETENTION_DAYS` now defaults to `7` days, independent of `DATA_RETENTION_DAYS` (previously fell back to it). Lower it on busy meshes or constrained storage. + +Because raw packets are pruned after 7 days, opening an old advert/message's packet link may 404 once the underlying packets have been cleaned up; the detail page now shows a friendly "Packet not found — it may have been cleaned up due to data retention" message instead of a generic error. + +**No migration or action required** beyond the defaults above; override either variable in your `.env` to restore prior behaviour. + ## v0.12.0 ### Multi-Worker API (`API_WORKERS`) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 27e0deb..f461f0c 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -51,6 +51,9 @@ def list_nodes( search: Optional[str] = Query( None, description="Search in name tag, node name, or public key" ), + pubkey_prefix: Optional[str] = Query( + None, description="Filter to nodes whose public key starts with this hex prefix" + ), adv_type: Optional[str] = Query(None, description="Filter by advertisement type"), adopted_by: Optional[str] = Query( None, description="Filter by adopting user profile UUID" @@ -89,6 +92,11 @@ def list_nodes( ) ) + if pubkey_prefix: + # Public keys are stored lowercase; lowercase the prefix for a + # case-insensitive startswith match (path-hash badges are hex prefixes). + query = query.where(Node.public_key.startswith(pubkey_prefix.lower())) + if adv_type: normalized_adv_type = adv_type.strip().lower() if normalized_adv_type == "repeater": diff --git a/src/meshcore_hub/api/routes/packet_groups.py b/src/meshcore_hub/api/routes/packet_groups.py index 35ec2dc..f5ac8b3 100644 --- a/src/meshcore_hub/api/routes/packet_groups.py +++ b/src/meshcore_hub/api/routes/packet_groups.py @@ -51,6 +51,14 @@ def _extract_path_hashes(decoded: dict[str, Any] | None) -> list[str] | None: return hashes if isinstance(hashes, list) else None +def _path_hash_byte_width(path_hashes: Optional[list[str]]) -> Optional[int]: + """Widest path-hash prefix width in bytes (each hash is hex: 2/4/6 chars).""" + if not path_hashes: + return None + widths = [len(h) // 2 for h in path_hashes if isinstance(h, str) and h] + return max(widths) if widths else None + + def _get_tag_name(node: Optional[Node]) -> Optional[str]: if not node or not node.tags: return None @@ -153,7 +161,9 @@ def list_packet_groups( group_query = group_query.offset(offset).limit(limit) group_rows = session.execute(group_query).all() - hashes = [r.packet_hash for r in group_rows] + # packet_hash is never None here (group_query filters is_not(None)); the + # explicit guard + annotation narrows the type for downstream dict lookups. + hashes: list[str] = [r.packet_hash for r in group_rows if r.packet_hash is not None] if not hashes: return GroupedPacketList(items=[], total=total, limit=limit, offset=offset) @@ -185,6 +195,20 @@ def list_packet_groups( group_counts = {r.packet_hash: r for r in group_rows} + # ── Phase 3: path-hash width — load decoded only for representative rows ─── + rep_id_to_hash = {rep.id: h for h, rep in representative.items()} + width_by_hash: dict[str, Optional[int]] = {} + if rep_id_to_hash: + decoded_rows = session.execute( + select(RawPacket.id, RawPacket.decoded).where( + RawPacket.id.in_(rep_id_to_hash.keys()) + ) + ).all() + for rid, decoded in decoded_rows: + h = rep_id_to_hash.get(rid) + if h is not None: + width_by_hash[h] = _path_hash_byte_width(_extract_path_hashes(decoded)) + items = [] for h in hashes: rep = representative.get(h) @@ -207,6 +231,7 @@ def list_packet_groups( ), reception_count=grp.reception_count, observer_count=grp.observer_count, + path_hash_bytes=(None if is_redacted else width_by_hash.get(h)), receptions=[], first_seen=grp.first_seen, redacted=is_redacted, diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py index cf79450..67d4373 100644 --- a/src/meshcore_hub/collector/subscriber.py +++ b/src/meshcore_hub/collector/subscriber.py @@ -49,7 +49,7 @@ class Subscriber(LetsMeshNormalizer): node_cleanup_days: int = 90, channel_refresh_interval_seconds: int = 300, raw_packet_capture_enabled: bool = False, - raw_packet_retention_days: int = 30, + raw_packet_retention_days: int = 7, ): """Initialize subscriber. @@ -651,7 +651,7 @@ def create_subscriber( node_cleanup_days: int = 90, channel_refresh_interval_seconds: int = 300, raw_packet_capture_enabled: bool = False, - raw_packet_retention_days: int = 30, + raw_packet_retention_days: int = 7, ) -> Subscriber: """Create a configured subscriber instance. @@ -735,7 +735,7 @@ def run_collector( node_cleanup_days: int = 90, channel_refresh_interval_seconds: int = 300, raw_packet_capture_enabled: bool = False, - raw_packet_retention_days: int = 30, + raw_packet_retention_days: int = 7, ) -> None: """Run the collector (blocking). diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 586a782..5efb880 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -149,9 +149,10 @@ class CollectorSettings(CommonSettings): description="Capture every inbound packets-feed packet into raw_packets", ) raw_packet_retention_days: Optional[int] = Field( - default=None, + default=7, description=( - "Days to retain raw packets (defaults to DATA_RETENTION_DAYS when unset)" + "Days to retain raw packets before cleanup (default 7, independent of " + "DATA_RETENTION_DAYS)" ), ge=1, ) @@ -432,7 +433,7 @@ class WebSettings(CommonSettings): default=True, description="Enable the /channels page" ) feature_packets: bool = Field( - default=False, description="Enable the /packets page (off by default)" + default=True, description="Enable the /packets page (on by default)" ) feature_pages: bool = Field( default=True, description="Enable custom markdown pages" diff --git a/src/meshcore_hub/common/schemas/raw_packets.py b/src/meshcore_hub/common/schemas/raw_packets.py index 2ddc312..6b5ba58 100644 --- a/src/meshcore_hub/common/schemas/raw_packets.py +++ b/src/meshcore_hub/common/schemas/raw_packets.py @@ -98,6 +98,13 @@ class GroupedPacketRead(BaseModel): source_pubkey_prefix: Optional[str] = Field(default=None) reception_count: int = Field(..., description="Total rows (paths × observers)") observer_count: int = Field(..., description="Distinct observer nodes") + path_hash_bytes: Optional[int] = Field( + default=None, + description=( + "Widest path-hash prefix width in bytes (1/2/3) for the " + "representative reception" + ), + ) receptions: list[PacketReceptionInfo] = Field( default_factory=list, description="Individual receptions (populated for detail, empty for list)", diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index e985fbb..1448ab4 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -545,74 +545,6 @@ export function observerIcons(observers) { return html`${observers.length}`; } -/** - * Render an expandable observer detail row. - * Shows per-observer: name, SNR, path_len, observed_at. - * @param {Array} observers - Array of observer objects - * @param {Object} [eventProperties] - Event-level context (unused, for future use) - * @returns {TemplateResult|nothing} - */ -export function observerDetailRow(observers, eventProperties, options = {}) { - if (!observers || observers.length === 0) return nothing; - const showPath = !options.hidePath; - return html` - - -
- - - - - - ${showPath ? html`` : nothing} - - - - - ${observers.map(o => { - const displayName = o.tag_name || o.name || truncateKey(o.public_key, 12); - const snrDisplay = o.snr != null ? `${Number(o.snr).toFixed(1)}` : '\u2014'; - const pathDisplay = o.path_len != null ? `${o.path_len}` : '\u2014'; - const timeDisplay = formatRelativeTime(o.observed_at); - return html` - - - - ${showPath ? html`` : nothing} - - - `; - })} - -
Observer${t('common.snr_db')}${t('common.hops')}Received
\u{1F4E1} ${displayName}${snrDisplay}${pathDisplay}${timeDisplay}
-
- - - `; -} - -/** - * Toggle observer detail row visibility when clicking an event row. - * @param {Event} event - Click event - */ -export function toggleObserverDetail(event) { - const row = event.currentTarget; - const detailRow = row.nextElementSibling; - if (detailRow && detailRow.classList.contains('observer-detail')) { - detailRow.classList.toggle('hidden'); - } -} - -export function toggleCardObserverDetail(event) { - event.stopPropagation(); - event.preventDefault(); - const card = event.currentTarget.closest('.card'); - if (card) { - const detail = card.querySelector('.observer-detail-card'); - if (detail) detail.classList.toggle('hidden'); - } -} - // --- Form Helpers --- /** diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js index 13e8b7f..ad05441 100644 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ b/src/meshcore_hub/web/static/js/spa/icons.js @@ -110,6 +110,14 @@ export function iconAntenna(cls = 'h-5 w-5') { return html``; } +export function iconSatelliteDish(cls = 'h-5 w-5') { + return html``; +} + +export function iconPath(cls = 'h-5 w-5') { + return html``; +} + export function iconSettings(cls = 'h-5 w-5') { return html``; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 07a812a..2776f98 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,30 +1,13 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, + getConfig, formatDateTime, formatDateTimeShort, warningBadge, pagination, sortableTableHeader, mobileSortSelect, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, - observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail + observerIcons } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; -import { iconPackets } from '../icons.js'; - -function packetLink(packetHash, navigate) { - if (!packetHash) { - return nothing; - } - const icon = iconPackets('h-5 w-5 nav-icon-packets'); - const title = t('packets.view_raw'); - const url = `/packets?packet_hash=${packetHash}`; - if (navigate) { - // Inside a card that is itself an : use a span to avoid nested anchors. - return html` { e.preventDefault(); e.stopPropagation(); navigate(url); }}>${icon}`; - } - return html` e.stopPropagation()}>${icon}`; -} function routeTypeBadge(routeType) { if (!routeType) { @@ -60,6 +43,17 @@ export async function render(container, params, router) { const tz = config.timezone || ''; const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; const navigate = (url) => router.navigate(url); + // For links nested inside a row/card whose own @click navigates elsewhere: + // suppress the row handler and drive SPA navigation explicitly (the router + // listens on document, so stopPropagation alone would force a full reload). + const stopAndNavigate = (url) => (e) => { + e.preventDefault(); + e.stopPropagation(); + navigate(url); + }; + // Packet-detail target for a row/card, or null when not navigable. + const packetDetailUrl = (packetHash) => + (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null; let lastContent = nothing; let lastTotal = null; @@ -143,54 +137,38 @@ ${displayContent}`, container); const adDescription = ad.node_tag_description; let receiversBlock = nothing; if (ad.observers && ad.observers.length >= 1) { - receiversBlock = html`${observerIcons(ad.observers)}`; + receiversBlock = observerIcons(ad.observers); } else if (ad.observed_by) { receiversBlock = html`\u{1F4E1}`; } - return html` + const detailUrl = packetDetailUrl(ad.packet_hash); + return html`
navigate(detailUrl) : undefined}>
- ${ad.observers && ad.observers.length > 0 ? html` - - ` : nothing}
- `; +
`; }); const tableRows = advertisements.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` + ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` : advertisements.map(ad => { const adName = ad.node_tag_name || ad.node_name || ad.name; const adDescription = ad.node_tag_description; @@ -202,9 +180,11 @@ ${displayContent}`, container); } else { receiversBlock = html`-`; } - return html` + const detailUrl = packetDetailUrl(ad.packet_hash); + return html` navigate(detailUrl) : undefined}> - + ${renderNodeDisplay({ name: adName, description: adDescription, @@ -222,8 +202,7 @@ ${displayContent}`, container); ${routeTypeBadge(ad.route_type)} ${formatDateTime(ad.received_at)} ${receiversBlock} - ${packetsEnabled ? html`${packetLink(ad.packet_hash)}` : nothing} - ${observerDetailRow(ad.observers || [], null, { hidePath: true })}`; + `; }); const paginationBlock = pagination(page, totalPages, '/advertisements', { @@ -320,7 +299,6 @@ ${mobileSortSelect({ ${t('advertisements.col_route_type')} ${sortable(t('common.time'), 'time')} ${t('common.observers')} - ${packetsEnabled ? html`` : nothing} diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 38abc74..b1cd807 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -1,30 +1,14 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, + getConfig, formatDateTime, formatDateTimeShort, getChannelLabelsMap, resolveChannelLabel, - truncateKey, warningBadge, + warningBadge, pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator, renderFilterCard, autoSubmit, submitOnEnter, - observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail + observerIcons } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; -import { iconPackets } from '../icons.js'; - -function packetLink(packetHash, navigate) { - if (!packetHash) { - return nothing; - } - const icon = iconPackets('h-5 w-5 nav-icon-packets'); - const title = t('packets.view_raw'); - const url = `/packets?packet_hash=${packetHash}`; - if (navigate) { - return html` { e.preventDefault(); e.stopPropagation(); navigate(url); }}>${icon}`; - } - return html` e.stopPropagation()}>${icon}`; -} export async function render(container, params, router) { const { signal } = params || {}; @@ -47,6 +31,9 @@ export async function render(container, params, router) { const tz = config.timezone || ''; const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; const navigate = (url) => router.navigate(url); + // Packet-detail target for a row/card, or null when not navigable. + const packetDetailUrl = (packetHash) => + (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null; function channelInfo(msg) { if (msg.message_type !== 'channel') { @@ -261,11 +248,13 @@ ${displayContent}`, container); : sender; let receiversBlock = nothing; if (msg.observers && msg.observers.length >= 1) { - receiversBlock = html`${observerIcons(msg.observers)}`; + receiversBlock = observerIcons(msg.observers); } else if (msg.observed_by) { receiversBlock = html`\u{1F4E1}`; } - return html`
+ const detailUrl = packetDetailUrl(msg.packet_hash); + return html`
navigate(detailUrl) : undefined}>
@@ -283,37 +272,15 @@ ${displayContent}`, container);
${receiversBlock} - ${packetsEnabled ? packetLink(msg.packet_hash) : nothing}

${displayMessage}

- ${msg.observers && msg.observers.length > 0 ? html` - - ` : nothing}
`; }); const tableRows = messages.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}` + ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}` : messages.map(msg => { const isChannel = msg.message_type === 'channel'; const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; @@ -332,7 +299,9 @@ ${displayContent}`, container); } else { receiversBlock = html`-`; } - return html` + const detailUrl = packetDetailUrl(msg.packet_hash); + return html` navigate(detailUrl) : undefined}> ${typeIcon} ${formatDateTime(msg.received_at)} @@ -340,8 +309,7 @@ ${displayContent}`, container); ${displayMessage} ${receiversBlock} - ${packetsEnabled ? html`${packetLink(msg.packet_hash)}` : nothing} - ${observerDetailRow(msg.observers || [])}`; + `; }); const paginationBlock = pagination(page, totalPages, '/messages', { @@ -447,7 +415,6 @@ ${mobileSortSelect({ ${sortable(t('common.from'), 'from')} ${sortable(t('entities.message'), 'message')} ${t('common.observers')} - ${packetsEnabled ? html`` : nothing} diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 2f0e2a5..aa9d363 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -14,6 +14,7 @@ export async function render(container, params, router) { const search = query.search || ''; const adv_type = query.adv_type || ''; const adopted_by = query.adopted_by || ''; + const pubkey_prefix = query.pubkey_prefix || ''; const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 20; const offset = (page - 1) * limit; @@ -56,6 +57,7 @@ ${displayContent}`, container); try { const apiParams = { limit, offset, search, adv_type, sort, order }; if (adopted_by) apiParams.adopted_by = adopted_by; + if (pubkey_prefix) apiParams.pubkey_prefix = pubkey_prefix; const fetches = [apiGet('/api/v1/nodes', apiParams, { signal })]; if (config.oidc_enabled) { fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal })); @@ -125,7 +127,7 @@ ${displayContent}`, container); }); const paginationBlock = pagination(page, totalPages, '/nodes', { - search, adv_type, adopted_by, limit, sort, order, + search, adv_type, adopted_by, pubkey_prefix, limit, sort, order, }); const filterFields = [ @@ -182,7 +184,7 @@ ${displayContent}`, container); defaultOpen: isFilterOpen, }); - const headerParams = { search, adv_type, adopted_by, limit }; + const headerParams = { search, adv_type, adopted_by, pubkey_prefix, limit }; const sortable = (label, sortKey) => sortableTableHeader(label, { sortKey, currentSort: sort, currentOrder: order, navigate, basePath: '/nodes', params: headerParams, 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 f29b650..2f61189 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 @@ -1,7 +1,8 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatRelativeTime, warningBadge, copyToClipboard + getConfig, formatDateTime, formatRelativeTime, warningBadge, copyToClipboard, + loading, truncateKey } from '../components.js'; function field(label, value) { @@ -18,11 +19,15 @@ 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}`; +// Max nodes listed in the path-hash lookup popover before linking out to the +// full (prefix-filtered) Nodes page. +const PATH_POPOVER_NODE_CAP = 8; + +// Render a single path-hash as a badge. Clicking it opens a popover listing the +// node(s) whose public key starts with this hash (see openPathPopover in render). +function pathBadge(hash, onClick) { + return html` onClick(e, hash)}>${hash}`; } const pathArrow = html``; @@ -38,17 +43,18 @@ function pathRow(badges) { return html`${parts}`; } -function formatPath(pathHashes, pathLen) { +function formatPath(pathHashes, pathLen, onBadgeClick) { + const badge = (h) => pathBadge(h, onBadgeClick); if (pathHashes && pathHashes.length > 0) { if (pathHashes.length <= PATH_MAX_BADGES) { - return pathRow(pathHashes.map(pathBadge)); + return pathRow(pathHashes.map(badge)); } const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL; const ellipsis = html``; const badges = [ - ...pathHashes.slice(0, PATH_HEAD).map(pathBadge), + ...pathHashes.slice(0, PATH_HEAD).map(badge), ellipsis, - ...pathHashes.slice(-PATH_TAIL).map(pathBadge), + ...pathHashes.slice(-PATH_TAIL).map(badge), ]; return pathRow(badges); } @@ -75,6 +81,159 @@ export async function render(container, params, router) { const tz = config.timezone || ''; const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; + // ── Path-hash → node lookup popover ─────────────────────────────────────── + // Clicking a path badge opens a single floating panel (appended to ) + // that lists the node(s) whose public key starts with that hex prefix. A + // prefix may match zero or more nodes. + let popoverEl = null; + let popoverListeners = null; + + function closePopover() { + if (popoverListeners) { + document.removeEventListener('click', popoverListeners.onDocClick); + document.removeEventListener('keydown', popoverListeners.onKey); + popoverListeners = null; + } + if (popoverEl) { + popoverEl.remove(); + popoverEl = null; + } + } + + function nodeDisplayName(n) { + const tagName = n.tags?.find(tag => tag.key === 'name')?.value; + return tagName || n.name || truncateKey(n.public_key, 12); + } + + function positionPopover(rect) { + if (!popoverEl) return; + const margin = 8; + const pw = popoverEl.offsetWidth || 256; + const ph = popoverEl.offsetHeight || 0; + let left = Math.min(rect.left, window.innerWidth - pw - margin); + if (left < margin) left = margin; + let top = rect.bottom + 4; + if (top + ph + margin > window.innerHeight && rect.top - ph - 4 > margin) { + top = rect.top - ph - 4; + } + popoverEl.style.left = `${left}px`; + popoverEl.style.top = `${top}px`; + } + + function popoverShell(hashLabel, body) { + return html` +
+ ${t('packets.path_nodes_title', { hash: hashLabel })} + +
+
${body}
`; + } + + async function openPathPopover(e, ph) { + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + closePopover(); + + popoverEl = document.createElement('div'); + popoverEl.className = 'path-node-popover fixed z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300'; + document.body.appendChild(popoverEl); + + litRender(popoverShell(ph, html`
${loading()}
`), popoverEl); + positionPopover(rect); + + const onDocClick = (ev) => { if (popoverEl && !popoverEl.contains(ev.target)) closePopover(); }; + const onKey = (ev) => { if (ev.key === 'Escape') closePopover(); }; + popoverListeners = { onDocClick, onKey }; + // Defer so the click that opened the popover doesn't immediately close it. + setTimeout(() => { + document.addEventListener('click', onDocClick); + document.addEventListener('keydown', onKey); + }, 0); + + try { + const data = await apiGet('/api/v1/nodes', + { pubkey_prefix: ph, sort: 'name', order: 'asc', limit: PATH_POPOVER_NODE_CAP }, + { signal }); + if (!popoverEl) return; // closed while loading + const items = (data.items || []).slice() + .sort((a, b) => nodeDisplayName(a).localeCompare(nodeDisplayName(b))); + const more = (data.total || 0) - items.length; + const body = items.length === 0 + ? html`
${t('packets.path_no_nodes')}
` + : html``; + litRender(popoverShell(ph, body), popoverEl); + positionPopover(rect); + } catch (err) { + if (isAbortError(err) || !popoverEl) return; + litRender(popoverShell(ph, html`
${warningBadge(err.message)}
`), popoverEl); + positionPopover(rect); + } + } + + // ── Per-observer reception rendering ────────────────────────────────────── + const hopsValue = (r) => (r.path_len != null ? r.path_len : '—'); + const snrValue = (r) => (r.snr != null ? Number(r.snr).toFixed(1) : '—'); + const timeValue = (r) => html`${formatRelativeTime(r.received_at)}`; + + function stat(label, value) { + return html`
+ ${label} + ${value} +
`; + } + + // Mobile (< lg): one card per reception, path full-width on top, stats below. + function receptionCards(recs) { + return html`
+ ${recs.map(r => html` +
+
${formatPath(r.path_hashes, r.path_len, openPathPopover)}
+
+ ${stat(t('common.time'), timeValue(r))} + ${stat(t('common.hops'), hopsValue(r))} + ${stat(t('common.snr_db'), snrValue(r))} +
+
`)} +
`; + } + + // Desktop (lg+): table-fixed so the right-aligned stat columns line up across + // every observer block regardless of path length. + function receptionTable(recs) { + return html``; + } + function shell(content, leaf) { litRender(html` -
- - - - - - - - - - - ${recs.map(r => html` - - - - - - `)} - -
${t('packets.col_path')}${t('common.hops')}${t('common.snr_db')}${t('common.time')}
${formatPath(r.path_hashes, r.path_len)}${r.path_len != null ? r.path_len : '—'}${r.snr != null ? Number(r.snr).toFixed(1) : '—'} - ${formatRelativeTime(r.received_at)} -
-
+ ${receptionCards(recs)} + ${receptionTable(recs)}
`; })} ` @@ -214,11 +352,14 @@ ${redactedNotice} `, g.packet_hash || g.event_type); } catch (e) { - if (isAbortError(e)) return; + if (isAbortError(e)) return closePopover; if (e.status === 404) { - shell(html`
${t('common.entity_not_found_details', { entity: t('entities.packet').toLowerCase(), details: hash })}
`); - return; + shell(html`
${t('packets.not_found_retention')}
`, t('entities.packet')); + return closePopover; } shell(warningBadge(e.message)); } + + // Tear down any open popover (and its global listeners) on navigation. + return closePopover; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/packets.js b/src/meshcore_hub/web/static/js/spa/pages/packets.js index 99b4508..43937e4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/packets.js +++ b/src/meshcore_hub/web/static/js/spa/pages/packets.js @@ -7,6 +7,7 @@ import { renderFilterCard, autoSubmit, submitOnEnter } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; +import { iconSatelliteDish, iconPath } from '../icons.js'; const EVENT_TYPES = [ 'advertisement', 'channel_msg_recv', 'contact_msg_recv', @@ -32,14 +33,20 @@ function channelLabel(packet, channelNames) { function receptionBadge(packet) { const rc = packet.reception_count ?? 1; const oc = packet.observer_count ?? 1; - return html`${rc} × ${oc}`; + const pb = packet.path_hash_bytes; + return html` + ${iconSatelliteDish('h-4 w-4 opacity-70')} + ${oc} + ${iconPath('h-4 w-4 opacity-70')} + ${rc} + ${pb ? html`${t('packets.path_width_bytes', { count: pb })}` : nothing} + `; } export async function render(container, params, router) { const { signal } = params || {}; const query = params.query || {}; const search = query.search || ''; - const packet_hash = query.packet_hash || ''; const event_type = query.event_type || ''; const channel_idx = query.channel_idx || ''; const page = parseInt(query.page, 10) || 1; @@ -83,7 +90,6 @@ ${displayContent}`, container); async function fetchAndRenderData() { try { const apiParams = { limit, offset, search, sort, order }; - if (packet_hash) apiParams.packet_hash = packet_hash; if (event_type) apiParams.event_type = event_type; if (channel_idx !== '') apiParams.channel_idx = channel_idx; @@ -139,7 +145,7 @@ ${displayContent}`, container); `); const paginationBlock = pagination(page, totalPages, '/packets', { - search, packet_hash, event_type, channel_idx, limit, sort, order, + search, event_type, channel_idx, limit, sort, order, }); const filterFields = [ @@ -178,23 +184,13 @@ ${displayContent}`, container); defaultOpen: isFilterOpen, }); - const headerParams = { search, packet_hash, event_type, channel_idx, limit }; + const headerParams = { search, event_type, channel_idx, limit }; const sortable = (label, sortKey) => sortableTableHeader(label, { sortKey, currentSort: sort, currentOrder: order, navigate, basePath: '/packets', params: headerParams, }); - const packetHashChip = packet_hash - ? html`
- - ${packet_hash} - - ${t('common.clear_filters')} -
` - : nothing; - renderPage(html`${filterCard} -${packetHashChip} ${mobileSortSelect({ currentSort: sort, currentOrder: order, diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 0ec6602..0ad7766 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -234,6 +234,12 @@ "col_path": "Path", "col_receptions": "Receptions", "hops_hidden": "{{count}} hops hidden", + "path_nodes_title": "Nodes matching {{hash}}", + "path_no_nodes": "No nodes found", + "path_nodes_more": "+{{count}} more — view all →", + "not_found_retention": "Packet not found — it may have been cleaned up due to data retention.", + "path_width_bytes": "{{count}}B", + "path_width_title": "Path hash width (bytes)", "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 1fe53ef..dd87b3b 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -174,6 +174,12 @@ "col_route_type": "Routetype", "col_raw": "Ruw", "hops_hidden": "{{count}} hops verborgen", + "path_nodes_title": "Knooppunten voor {{hash}}", + "path_no_nodes": "Geen knooppunten gevonden", + "path_nodes_more": "+{{count}} meer — alle bekijken →", + "not_found_retention": "Pakket niet gevonden — mogelijk opgeschoond door dataretentie.", + "path_width_bytes": "{{count}}B", + "path_width_title": "Breedte pad-hash (bytes)", "sort": { "newest": "Tijd (nieuwste)", "oldest": "Tijd (oudste)", diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 30fd9dd..ac794a4 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -168,6 +168,48 @@ class TestListNodesFilters: assert room_node.public_key in room_keys assert name_only_room_node.public_key not in room_keys + def test_filter_by_pubkey_prefix(self, client_no_auth, api_db_session): + """pubkey_prefix returns only nodes whose public key starts with it.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + match_a = Node( + public_key="ab" + "0" * 62, first_seen=datetime.now(timezone.utc) + ) + match_b = Node( + public_key="ab" + "1" * 62, first_seen=datetime.now(timezone.utc) + ) + non_match = Node( + public_key="cd" + "0" * 62, first_seen=datetime.now(timezone.utc) + ) + api_db_session.add_all([match_a, match_b, non_match]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?pubkey_prefix=ab") + assert response.status_code == 200 + keys = {item["public_key"] for item in response.json()["items"]} + assert match_a.public_key in keys + assert match_b.public_key in keys + assert non_match.public_key not in keys + + def test_filter_by_pubkey_prefix_is_case_insensitive( + self, client_no_auth, api_db_session + ): + """An uppercase prefix matches lowercase-stored public keys.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node = Node(public_key="ab" + "0" * 62, first_seen=datetime.now(timezone.utc)) + api_db_session.add(node) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?pubkey_prefix=AB") + assert response.status_code == 200 + keys = {item["public_key"] for item in response.json()["items"]} + assert node.public_key in keys + def test_filter_by_observer_true( self, client_no_auth, api_db_session, receiver_node ): diff --git a/tests/test_api/test_packet_groups.py b/tests/test_api/test_packet_groups.py index e863090..23b0f3e 100644 --- a/tests/test_api/test_packet_groups.py +++ b/tests/test_api/test_packet_groups.py @@ -299,6 +299,83 @@ class TestListPacketGroups: assert "limit=10" in key +class TestPathHashBytes: + """Tests for the derived path_hash_bytes field on the list endpoint.""" + + def test_one_byte_path(self, client_no_auth, api_db_session): + api_db_session.add( + RawPacket( + raw_hex="AA", + packet_hash="H1", + decoded={"path": ["aa", "bb"]}, + received_at=_now(), + ) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["path_hash_bytes"] == 1 + + def test_two_byte_path(self, client_no_auth, api_db_session): + api_db_session.add( + RawPacket( + raw_hex="AA", + packet_hash="H1", + decoded={"path": ["aabb"]}, + received_at=_now(), + ) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["path_hash_bytes"] == 2 + + def test_mixed_path_uses_max(self, client_no_auth, api_db_session): + api_db_session.add( + RawPacket( + raw_hex="AA", + packet_hash="H1", + decoded={"path": ["aa", "aabb"]}, + received_at=_now(), + ) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["path_hash_bytes"] == 2 + + def test_no_path_returns_none(self, client_no_auth, api_db_session): + api_db_session.add( + RawPacket(raw_hex="AA", packet_hash="H1", received_at=_now()) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["path_hash_bytes"] is None + + def test_redacted_path_width_hidden(self, client_no_auth, api_db_session): + adm_key = "FFEEDDCCBBAA99887766554433221100" + adm_idx = int(Channel.compute_channel_hash(adm_key), 16) + api_db_session.add( + Channel( + name="Adm", + key_hex=adm_key, + channel_hash=Channel.compute_channel_hash(adm_key), + visibility="admin", + enabled=True, + ) + ) + api_db_session.add( + RawPacket( + raw_hex="SECRET", + packet_hash="ADM_HASH", + channel_idx=adm_idx, + decoded={"path": ["aabb"]}, + received_at=_now(), + ) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["redacted"] is True + assert item["path_hash_bytes"] is None + + class TestGetPacketGroup: """Tests for GET /packet-groups/{hash} (detail).""" diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index 8555c49..d5bb5d4 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -51,12 +51,12 @@ class TestCollectorSettings: assert settings.database_url == "postgresql://user@host/db" assert settings.effective_database_url == "postgresql://user@host/db" - def test_raw_packet_retention_defaults_to_global(self) -> None: - """Unset raw_packet_retention_days resolves to data_retention_days.""" + def test_raw_packet_retention_defaults_to_7(self) -> None: + """Unset raw_packet_retention_days defaults to 7, independent of global.""" settings = CollectorSettings(_env_file=None, data_retention_days=12) - assert settings.raw_packet_retention_days is None - assert settings.effective_raw_packet_retention_days == 12 + assert settings.raw_packet_retention_days == 7 + assert settings.effective_raw_packet_retention_days == 7 def test_raw_packet_retention_explicit_override(self) -> None: """An explicit raw_packet_retention_days wins over the global value.""" diff --git a/tests/test_web/test_features.py b/tests/test_web/test_features.py index 639cc15..9fc29bf 100644 --- a/tests/test_web/test_features.py +++ b/tests/test_web/test_features.py @@ -226,12 +226,12 @@ class TestPacketsFeatureFlag: html = client.get("/").text assert 'href="/packets"' in html - def test_packets_disabled_by_default_in_settings(self) -> None: - """The declared default for feature_packets is False (env-independent).""" + def test_packets_enabled_by_default_in_settings(self) -> None: + """The declared default for feature_packets is True (env-independent).""" from meshcore_hub.common.config import WebSettings # Check the field default directly so a local .env cannot mask it. - assert WebSettings.model_fields["feature_packets"].default is False + assert WebSettings.model_fields["feature_packets"].default is True class TestFeatureFlagsIndividual: