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].
This commit is contained in:
Louis King
2026-06-13 16:47:22 +01:00
parent 81c6b3e989
commit 87e7d7676b
23 changed files with 472 additions and 257 deletions
+7 -7
View File
@@ -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
+3 -3
View File
@@ -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=<hex>` (case-insensitive `startswith` on `public_key`). Raw-packet retention cleanup runs whenever data cleanup runs, regardless of the capture flag.
**Node Cleanup:**
+4
View File
@@ -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`).
+5 -5
View File
@@ -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:
+19 -4
View File
@@ -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 13 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`)
+8
View File
@@ -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":
+26 -1
View File
@@ -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,
+3 -3
View File
@@ -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).
+4 -3
View File
@@ -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"
@@ -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)",
@@ -545,74 +545,6 @@ export function observerIcons(observers) {
return html`<span class="badge badge-sm badge-primary cursor-help observer-badge" title=${tooltip}>${observers.length}</span>`;
}
/**
* 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`
<tr class="observer-detail hidden">
<td colspan="100" class="p-0">
<div class="observer-detail-content">
<table class="table table-xs w-full">
<thead>
<tr>
<th>Observer</th>
<th>${t('common.snr_db')}</th>
${showPath ? html`<th>${t('common.hops')}</th>` : nothing}
<th>Received</th>
</tr>
</thead>
<tbody>
${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`
<tr>
<td>\u{1F4E1} <a href="/nodes/${o.public_key}" class="link link-hover">${displayName}</a></td>
<td>${snrDisplay}</td>
${showPath ? html`<td>${pathDisplay}</td>` : nothing}
<td><span title=${formatDateTime(o.observed_at)}>${timeDisplay}</span></td>
</tr>
`;
})}
</tbody>
</table>
</div>
</td>
</tr>
`;
}
/**
* 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 ---
/**
@@ -110,6 +110,14 @@ export function iconAntenna(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" /></svg>`;
}
export function iconSatelliteDish(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10a7.31 7.31 0 0 0 10 10Z"/><path d="m9 15 3-3"/><path d="M17 13a6 6 0 0 0-6-6"/><path d="M21 13A10 10 0 0 0 11 3"/></svg>`;
}
export function iconPath(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" /></svg>`;
}
export function iconSettings(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>`;
}
@@ -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 <a>: use a span to avoid nested anchors.
return html`<span class="inline-flex cursor-pointer" title="${title}"
@click=${(e) => { e.preventDefault(); e.stopPropagation(); navigate(url); }}>${icon}</span>`;
}
return html`<a href="${url}" class="inline-flex" title="${title}"
@click=${(e) => e.stopPropagation()}>${icon}</a>`;
}
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`<span class="text-sm opacity-60">${tz}</span>` : 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`<span @click=${toggleCardObserverDetail} class="cursor-pointer">${observerIcons(ad.observers)}</span>`;
receiversBlock = observerIcons(ad.observers);
} else if (ad.observed_by) {
receiversBlock = html`<span class="opacity-50 text-xs">\u{1F4E1}</span>`;
}
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
const detailUrl = packetDetailUrl(ad.packet_hash);
return html`<div class="card bg-base-100 shadow-sm block ${detailUrl ? 'cursor-pointer' : ''}"
@click=${detailUrl ? () => navigate(detailUrl) : undefined}>
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'sm'
})}
<a href="/nodes/${ad.public_key}" class="min-w-0" @click=${stopAndNavigate(`/nodes/${ad.public_key}`)}>
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'sm'
})}
</a>
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${formatDateTimeShort(ad.received_at)}</div>
<div class="flex items-center justify-end gap-1">
${routeTypeBadge(ad.route_type)}
${receiversBlock}
${packetsEnabled ? packetLink(ad.packet_hash, navigate) : nothing}
</div>
</div>
</div>
${ad.observers && ad.observers.length > 0 ? html`
<div class="observer-detail-card hidden mt-2">
<table class="table table-xs w-full">
<thead><tr><th>Observer</th><th>${t('common.snr_db')}</th><th>Received</th></tr></thead>
<tbody>
${ad.observers.map(o => {
const dn = o.tag_name || o.name || o.public_key.slice(0, 12);
const snrD = o.snr != null ? `${Number(o.snr).toFixed(1)}` : '\u2014';
const timeD = formatRelativeTime(o.observed_at);
return html`<tr>
<td>\u{1F4E1} <a href="/nodes/${o.public_key}" class="link link-hover">${dn}</a></td>
<td>${snrD}</td>
<td><span title=${formatDateTime(o.observed_at)}>${timeD}</span></td>
</tr>`;
})}
</tbody>
</table>
</div>
` : nothing}
</div>
</a>`;
</div>`;
});
const tableRows = advertisements.length === 0
? html`<tr><td colspan=${packetsEnabled ? 6 : 5} class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
: 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`<span class="opacity-50">-</span>`;
}
return html`<tr class="hover cursor-pointer" @click=${toggleObserverDetail}>
const detailUrl = packetDetailUrl(ad.packet_hash);
return html`<tr class="${detailUrl ? 'hover cursor-pointer' : ''}"
@click=${detailUrl ? () => navigate(detailUrl) : undefined}>
<td>
<a href="/nodes/${ad.public_key}" class="link link-hover">
<a href="/nodes/${ad.public_key}" class="link link-hover" @click=${stopAndNavigate(`/nodes/${ad.public_key}`)}>
${renderNodeDisplay({
name: adName,
description: adDescription,
@@ -222,8 +202,7 @@ ${displayContent}`, container);
<td>${routeTypeBadge(ad.route_type)}</td>
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
<td>${receiversBlock}</td>
${packetsEnabled ? html`<td>${packetLink(ad.packet_hash)}</td>` : nothing}
</tr>${observerDetailRow(ad.observers || [], null, { hidePath: true })}`;
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
@@ -320,7 +299,6 @@ ${mobileSortSelect({
<th>${t('advertisements.col_route_type')}</th>
${sortable(t('common.time'), 'time')}
<th>${t('common.observers')}</th>
${packetsEnabled ? html`<th></th>` : nothing}
</tr>
</thead>
<tbody>
@@ -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`<span class="inline-flex cursor-pointer" title="${title}"
@click=${(e) => { e.preventDefault(); e.stopPropagation(); navigate(url); }}>${icon}</span>`;
}
return html`<a href="${url}" class="inline-flex" title="${title}"
@click=${(e) => e.stopPropagation()}>${icon}</a>`;
}
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`<span class="text-sm opacity-60">${tz}</span>` : 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`<span @click=${toggleCardObserverDetail} class="cursor-pointer">${observerIcons(msg.observers)}</span>`;
receiversBlock = observerIcons(msg.observers);
} else if (msg.observed_by) {
receiversBlock = html`<span class="opacity-50 text-xs">\u{1F4E1}</span>`;
}
return html`<div class="card bg-base-100 shadow-sm">
const detailUrl = packetDetailUrl(msg.packet_hash);
return html`<div class="card bg-base-100 shadow-sm ${detailUrl ? 'cursor-pointer' : ''}"
@click=${detailUrl ? () => navigate(detailUrl) : undefined}>
<div class="card-body p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
@@ -283,37 +272,15 @@ ${displayContent}`, container);
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${receiversBlock}
${packetsEnabled ? packetLink(msg.packet_hash) : nothing}
</div>
</div>
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${displayMessage}</p>
${msg.observers && msg.observers.length > 0 ? html`
<div class="observer-detail-card hidden mt-2">
<table class="table table-xs w-full">
<thead><tr><th>Observer</th><th>${t('common.snr_db')}</th><th>${t('common.hops')}</th><th>Received</th></tr></thead>
<tbody>
${msg.observers.map(o => {
const dn = o.tag_name || o.name || truncateKey(o.public_key, 12);
const snrD = o.snr != null ? `${Number(o.snr).toFixed(1)}` : '\u2014';
const pathD = o.path_len != null ? `${o.path_len}` : '\u2014';
const timeD = formatRelativeTime(o.observed_at);
return html`<tr>
<td>\u{1F4E1} <a href="/nodes/${o.public_key}" class="link link-hover">${dn}</a></td>
<td>${snrD}</td>
<td>${pathD}</td>
<td><span title=${formatDateTime(o.observed_at)}>${timeD}</span></td>
</tr>`;
})}
</tbody>
</table>
</div>
` : nothing}
</div>
</div>`;
});
const tableRows = messages.length === 0
? html`<tr><td colspan=${packetsEnabled ? 6 : 5} class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
: 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`<span class="opacity-50">-</span>`;
}
return html`<tr class="hover cursor-pointer" @click=${toggleObserverDetail}>
const detailUrl = packetDetailUrl(msg.packet_hash);
return html`<tr class="${detailUrl ? 'hover cursor-pointer' : ''}"
@click=${detailUrl ? () => navigate(detailUrl) : undefined}>
<td class="text-lg" title=${typeTitle}>${typeIcon}</td>
<td class="text-sm whitespace-nowrap">${formatDateTime(msg.received_at)}</td>
<td class="text-sm whitespace-nowrap">
@@ -340,8 +309,7 @@ ${displayContent}`, container);
</td>
<td class="break-words max-w-md" style="white-space: pre-wrap;">${displayMessage}</td>
<td>${receiversBlock}</td>
${packetsEnabled ? html`<td>${packetLink(msg.packet_hash)}</td>` : nothing}
</tr>${observerDetailRow(msg.observers || [])}`;
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/messages', {
@@ -447,7 +415,6 @@ ${mobileSortSelect({
${sortable(t('common.from'), 'from')}
${sortable(t('entities.message'), 'message')}
<th>${t('common.observers')}</th>
${packetsEnabled ? html`<th></th>` : nothing}
</tr>
</thead>
<tbody>
@@ -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,
@@ -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`<span class="badge badge-sm badge-ghost font-mono text-xs path-hash-badge cursor-help" data-path-hash=${hash}>${hash}</span>`;
// 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`<span class="badge badge-sm badge-primary font-mono text-xs path-hash-badge cursor-pointer"
data-path-hash=${hash} @click=${(e) => onClick(e, hash)}>${hash}</span>`;
}
const pathArrow = html`<span class="opacity-40 text-xs">→</span>`;
@@ -38,17 +43,18 @@ function pathRow(badges) {
return html`<span class="flex flex-wrap items-center gap-1">${parts}</span>`;
}
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`<span class="badge badge-sm badge-ghost cursor-help" title=${t('packets.hops_hidden', { count: hidden })}>…</span>`;
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`<span class="text-sm opacity-60">${tz}</span>` : nothing;
// ── Path-hash → node lookup popover ───────────────────────────────────────
// Clicking a path badge opens a single floating panel (appended to <body>)
// 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`
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-base-200 sticky top-0 bg-base-100 rounded-t-box">
<span class="text-xs font-semibold uppercase opacity-70">${t('packets.path_nodes_title', { hash: hashLabel })}</span>
<button class="btn btn-xs btn-ghost btn-circle" aria-label=${t('common.close')} @click=${closePopover}></button>
</div>
<div>${body}</div>`;
}
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`<div class="p-3">${loading()}</div>`), 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`<div class="px-3 py-4 text-sm opacity-60 text-center">${t('packets.path_no_nodes')}</div>`
: html`<ul class="menu menu-sm w-full">
${items.map(n => html`<li>
<a href="/nodes/${n.public_key}" @click=${closePopover} class="flex flex-col items-start gap-0">
<span class="text-sm">${nodeDisplayName(n)}</span>
<span class="font-mono text-xs opacity-50">${truncateKey(n.public_key, 16)}</span>
</a></li>`)}
${more > 0 ? html`<li>
<a href="/nodes?pubkey_prefix=${ph}" @click=${closePopover} class="text-xs opacity-70">
${t('packets.path_nodes_more', { count: more })}
</a></li>` : nothing}
</ul>`;
litRender(popoverShell(ph, body), popoverEl);
positionPopover(rect);
} catch (err) {
if (isAbortError(err) || !popoverEl) return;
litRender(popoverShell(ph, html`<div class="p-3">${warningBadge(err.message)}</div>`), 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`<span title=${formatDateTime(r.received_at)}>${formatRelativeTime(r.received_at)}</span>`;
function stat(label, value) {
return html`<div class="flex flex-col">
<span class="text-[10px] uppercase opacity-60">${label}</span>
<span class="text-sm">${value}</span>
</div>`;
}
// Mobile (< lg): one card per reception, path full-width on top, stats below.
function receptionCards(recs) {
return html`<div class="lg:hidden space-y-2">
${recs.map(r => html`
<div class="rounded-box bg-base-200/60 p-3">
<div class="mb-2">${formatPath(r.path_hashes, r.path_len, openPathPopover)}</div>
<div class="grid grid-cols-3 gap-2">
${stat(t('common.time'), timeValue(r))}
${stat(t('common.hops'), hopsValue(r))}
${stat(t('common.snr_db'), snrValue(r))}
</div>
</div>`)}
</div>`;
}
// 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`<div class="hidden lg:block overflow-x-auto">
<table class="table table-xs table-fixed w-full">
<thead>
<tr>
<th>${t('packets.col_path')}</th>
<th class="w-16 text-right">${t('common.hops')}</th>
<th class="w-20 text-right">${t('common.snr_db')}</th>
<th class="w-32 text-right">${t('common.time')}</th>
</tr>
</thead>
<tbody>
${recs.map(r => html`
<tr>
<td class="whitespace-normal align-top">${formatPath(r.path_hashes, r.path_len, openPathPopover)}</td>
<td class="w-16 text-right text-sm align-top">${hopsValue(r)}</td>
<td class="w-20 text-right text-sm align-top">${snrValue(r)}</td>
<td class="w-32 text-right text-xs opacity-60 align-top whitespace-nowrap">${timeValue(r)}</td>
</tr>`)}
</tbody>
</table>
</div>`;
}
function shell(content, leaf) {
litRender(html`
<div class="breadcrumbs text-sm mb-4">
@@ -159,29 +318,8 @@ ${content}`, container);
? html`<span class="text-xs opacity-50 ml-1">(${recs.length} ${t('packets.reception_plural')})</span>`
: nothing}
</div>
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr>
<th>${t('packets.col_path')}</th>
<th>${t('common.hops')}</th>
<th>${t('common.snr_db')}</th>
<th>${t('common.time')}</th>
</tr>
</thead>
<tbody>
${recs.map(r => html`
<tr>
<td class="max-w-[60vw] sm:max-w-md whitespace-normal align-top">${formatPath(r.path_hashes, r.path_len)}</td>
<td class="text-sm">${r.path_len != null ? r.path_len : '—'}</td>
<td class="text-sm">${r.snr != null ? Number(r.snr).toFixed(1) : '—'}</td>
<td class="text-xs opacity-60">
<span title=${formatDateTime(r.received_at)}>${formatRelativeTime(r.received_at)}</span>
</td>
</tr>`)}
</tbody>
</table>
</div>
${receptionCards(recs)}
${receptionTable(recs)}
</div>`;
})}
</div>`
@@ -214,11 +352,14 @@ ${redactedNotice}
</div>`, g.packet_hash || g.event_type);
} catch (e) {
if (isAbortError(e)) return;
if (isAbortError(e)) return closePopover;
if (e.status === 404) {
shell(html`<div class="alert alert-error">${t('common.entity_not_found_details', { entity: t('entities.packet').toLowerCase(), details: hash })}</div>`);
return;
shell(html`<div class="alert alert-warning">${t('packets.not_found_retention')}</div>`, t('entities.packet'));
return closePopover;
}
shell(warningBadge(e.message));
}
// Tear down any open popover (and its global listeners) on navigation.
return closePopover;
}
@@ -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`<span class="badge badge-sm badge-ghost font-mono">${rc} × ${oc}</span>`;
const pb = packet.path_hash_bytes;
return html`<span class="inline-flex items-center gap-1">
${iconSatelliteDish('h-4 w-4 opacity-70')}
<span class="badge badge-sm badge-primary" title=${t('common.observers')}>${oc}</span>
${iconPath('h-4 w-4 opacity-70')}
<span class="badge badge-sm badge-primary" title=${t('packets.reception_plural')}>${rc}</span>
${pb ? html`<span class="badge badge-sm badge-ghost" title=${t('packets.path_width_title')}>${t('packets.path_width_bytes', { count: pb })}</span>` : nothing}
</span>`;
}
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);
</tr>`);
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`<div class="flex items-center gap-2 mb-3">
<span class="badge badge-neutral inline-flex items-center">
<code class="font-mono text-xs leading-none">${packet_hash}</code>
</span>
<a href="/packets" class="btn btn-xs btn-ghost">${t('common.clear_filters')}</a>
</div>`
: nothing;
renderPage(html`${filterCard}
${packetHashChip}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
@@ -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)",
@@ -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)",
+42
View File
@@ -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
):
+77
View File
@@ -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)."""
+4 -4
View File
@@ -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."""
+3 -3
View File
@@ -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: