mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-11 03:13:00 +02:00
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:
+7
-7
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user