mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Compare commits
5 Commits
l5y-web-ap
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c416d029 | ||
|
|
8305ca588c | ||
|
|
0cf56b6fba | ||
|
|
ecce7f3504 | ||
|
|
17fa183c4f |
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.10</string>
|
||||
<string>0.5.11</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.5.10</string>
|
||||
<string>0.5.11</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: potato_mesh_reader
|
||||
description: Meshtastic Reader — read-only view for PotatoMesh messages.
|
||||
publish_to: "none"
|
||||
version: 0.5.10
|
||||
version: 0.5.11
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
|
||||
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
|
||||
message information before forwarding it to the accompanying web application.
|
||||
"""
|
||||
|
||||
VERSION = "0.5.10"
|
||||
VERSION = "0.5.11"
|
||||
"""Semantic version identifier shared with the dashboard and front-end."""
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
107
data/mesh_ingestor/CONTRACTS.md
Normal file
107
data/mesh_ingestor/CONTRACTS.md
Normal file
@@ -0,0 +1,107 @@
|
||||
## Mesh ingestor contracts (stable interfaces)
|
||||
|
||||
This repo’s ingestion pipeline is split into:
|
||||
|
||||
- **Python collector** (`data/mesh_ingestor/*`) which normalizes packets/events and POSTs JSON to the web app.
|
||||
- **Sinatra web app** (`web/`) which accepts those payloads on `POST /api/*` ingest routes and persists them into SQLite tables defined under `data/*.sql`.
|
||||
|
||||
This document records the **contracts that future providers must preserve**. The intent is to enable adding new providers (MeshCore, Reticulum, …) without changing the Ruby/DB/UI read-side.
|
||||
|
||||
### Canonical node identity
|
||||
|
||||
- **Canonical node id**: `nodes.node_id` is a `TEXT` primary key and is treated as canonical across the system.
|
||||
- **Format**: `!%08x` (lowercase hex, 8 chars), for example `!abcdef01`.
|
||||
- **Normalization**:
|
||||
- Python currently normalizes via `data/mesh_ingestor/serialization.py:_canonical_node_id`.
|
||||
- Ruby normalizes via `web/lib/potato_mesh/application/data_processing.rb:canonical_node_parts`.
|
||||
- **Dual addressing**: Ruby routes and queries accept either a canonical `!xxxxxxxx` string or a numeric node id; they normalize to `node_id`.
|
||||
|
||||
Note: non-Meshtastic providers will need a strategy to map their native node identifiers into this `!%08x` space. That mapping is intentionally not standardized in code yet.
|
||||
|
||||
### Ingest HTTP routes and payload shapes
|
||||
|
||||
Future providers should emit payloads that match these shapes (keys + types), which are validated by existing tests (notably `tests/test_mesh.py`).
|
||||
|
||||
#### `POST /api/nodes`
|
||||
|
||||
Payload is a mapping keyed by canonical node id:
|
||||
|
||||
- `{ "!abcdef01": { ... node fields ... } }`
|
||||
|
||||
Node entry fields are “Meshtastic-ish” (camelCase) and may include:
|
||||
|
||||
- `num` (int node number)
|
||||
- `lastHeard` (int unix seconds)
|
||||
- `snr` (float)
|
||||
- `hopsAway` (int)
|
||||
- `isFavorite` (bool)
|
||||
- `user` (mapping; e.g. `shortName`, `longName`, `macaddr`, `hwModel`, `role`, `publicKey`, `isUnmessagable`)
|
||||
- `deviceMetrics` (mapping; e.g. `batteryLevel`, `voltage`, `channelUtilization`, `airUtilTx`, `uptimeSeconds`)
|
||||
- `position` (mapping; `latitude`, `longitude`, `altitude`, `time`, `locationSource`, `precisionBits`, optional nested `raw`)
|
||||
- Optional radio metadata: `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/messages`
|
||||
|
||||
Single message payload:
|
||||
|
||||
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
|
||||
- Identity: `from_id` (string/int), `to_id` (string/int), `channel` (int), `portnum` (string|nil)
|
||||
- Payload: `text` (string|nil), `encrypted` (string|nil), `reply_id` (int|nil), `emoji` (string|nil)
|
||||
- RF: `snr` (float|nil), `rssi` (int|nil), `hop_limit` (int|nil)
|
||||
- Meta: `channel_name` (string; only when not encrypted and known), `ingestor` (canonical host id), `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/positions`
|
||||
|
||||
Single position payload:
|
||||
|
||||
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
|
||||
- Node: `node_id` (canonical string), `node_num` (int|nil), `num` (int|nil), `from_id` (canonical string), `to_id` (string|nil)
|
||||
- Position: `latitude`, `longitude`, `altitude` (floats|nil)
|
||||
- Position time: `position_time` (int|nil)
|
||||
- Quality: `location_source` (string|nil), `precision_bits` (int|nil), `sats_in_view` (int|nil), `pdop` (float|nil)
|
||||
- Motion: `ground_speed` (float|nil), `ground_track` (float|nil)
|
||||
- RF/meta: `snr`, `rssi`, `hop_limit`, `bitfield`, `payload_b64` (string|nil), `raw` (mapping|nil), `ingestor`, `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/telemetry`
|
||||
|
||||
Single telemetry payload:
|
||||
|
||||
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
|
||||
- Node: `node_id` (canonical string|nil), `node_num` (int|nil), `from_id`, `to_id`
|
||||
- Time: `telemetry_time` (int|nil)
|
||||
- Packet: `channel` (int), `portnum` (string|nil), `bitfield` (int|nil), `hop_limit` (int|nil)
|
||||
- RF: `snr` (float|nil), `rssi` (int|nil)
|
||||
- Raw: `payload_b64` (string; may be empty string when unknown)
|
||||
- Metrics: many optional snake_case keys (`battery_level`, `voltage`, `temperature`, etc.)
|
||||
- Meta: `ingestor`, `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/neighbors`
|
||||
|
||||
Neighbors snapshot payload:
|
||||
|
||||
- Node: `node_id` (canonical string), `node_num` (int|nil)
|
||||
- `neighbors`: list of entries with `neighbor_id` (canonical string), `neighbor_num` (int|nil), `snr` (float|nil), `rx_time` (int), `rx_iso` (string)
|
||||
- Snapshot time: `rx_time`, `rx_iso`
|
||||
- Optional: `node_broadcast_interval_secs` (int|nil), `last_sent_by_id` (canonical string|nil)
|
||||
- Meta: `ingestor`, `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/traces`
|
||||
|
||||
Single trace payload:
|
||||
|
||||
- Identity: `id` (int|nil), `request_id` (int|nil)
|
||||
- Endpoints: `src` (int|nil), `dest` (int|nil)
|
||||
- Path: `hops` (list[int])
|
||||
- Time: `rx_time` (int), `rx_iso` (string)
|
||||
- Metrics: `rssi` (int|nil), `snr` (float|nil), `elapsed_ms` (int|nil)
|
||||
- Meta: `ingestor`, `lora_freq`, `modem_preset`
|
||||
|
||||
#### `POST /api/ingestors`
|
||||
|
||||
Heartbeat payload:
|
||||
|
||||
- `node_id` (canonical string)
|
||||
- `start_time` (int), `last_seen_time` (int)
|
||||
- `version` (string)
|
||||
- Optional: `lora_freq`, `modem_preset`
|
||||
|
||||
@@ -24,6 +24,8 @@ import time
|
||||
from pubsub import pub
|
||||
|
||||
from . import config, handlers, ingestors, interfaces
|
||||
from .provider import Provider
|
||||
from .providers.meshtastic import MeshtasticProvider
|
||||
|
||||
_RECEIVE_TOPICS = (
|
||||
"meshtastic.receive",
|
||||
@@ -243,10 +245,12 @@ def _connected_state(candidate) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
def main(existing_interface=None) -> None:
|
||||
def main(existing_interface=None, *, provider: Provider | None = None) -> None:
|
||||
"""Run the mesh ingestion daemon until interrupted."""
|
||||
|
||||
subscribed = _subscribe_receive_topics()
|
||||
provider = provider or MeshtasticProvider()
|
||||
|
||||
subscribed = provider.subscribe()
|
||||
if subscribed:
|
||||
config._debug_log(
|
||||
"Subscribed to receive topics",
|
||||
@@ -312,17 +316,11 @@ def main(existing_interface=None) -> None:
|
||||
while not stop.is_set():
|
||||
if iface is None:
|
||||
try:
|
||||
if active_candidate:
|
||||
iface, resolved_target = interfaces._create_serial_interface(
|
||||
active_candidate
|
||||
)
|
||||
else:
|
||||
iface, resolved_target = interfaces._create_default_interface()
|
||||
active_candidate = resolved_target
|
||||
interfaces._ensure_radio_metadata(iface)
|
||||
interfaces._ensure_channel_metadata(iface)
|
||||
iface, resolved_target, active_candidate = provider.connect(
|
||||
active_candidate=active_candidate
|
||||
)
|
||||
handlers.register_host_node_id(
|
||||
interfaces._extract_host_node_id(iface)
|
||||
provider.extract_host_node_id(iface)
|
||||
)
|
||||
ingestors.set_ingestor_node_id(handlers.host_node_id())
|
||||
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
|
||||
@@ -415,8 +413,8 @@ def main(existing_interface=None) -> None:
|
||||
|
||||
if not initial_snapshot_sent:
|
||||
try:
|
||||
nodes = getattr(iface, "nodes", {}) or {}
|
||||
node_items = _node_items_snapshot(nodes)
|
||||
node_items = list(provider.node_snapshot_items(iface))
|
||||
node_items = _node_items_snapshot(dict(node_items))
|
||||
if node_items is None:
|
||||
config._debug_log(
|
||||
"Skipping node snapshot due to concurrent modification",
|
||||
|
||||
164
data/mesh_ingestor/events.py
Normal file
164
data/mesh_ingestor/events.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Protocol-agnostic event payload types for ingestion.
|
||||
|
||||
The ingestor ultimately POSTs JSON to the web app's ingest routes. These types
|
||||
capture the *shape* of those payloads so multiple providers can emit the same
|
||||
events, regardless of how they source or decode packets.
|
||||
|
||||
These are intentionally defined as ``TypedDict`` so existing code can continue
|
||||
to build plain dictionaries without a runtime dependency on dataclasses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
|
||||
class MessageEvent(TypedDict, total=False):
|
||||
id: int
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
from_id: object
|
||||
to_id: object
|
||||
channel: int
|
||||
portnum: str | None
|
||||
text: str | None
|
||||
encrypted: str | None
|
||||
snr: float | None
|
||||
rssi: int | None
|
||||
hop_limit: int | None
|
||||
reply_id: int | None
|
||||
emoji: str | None
|
||||
channel_name: str
|
||||
ingestor: str | None
|
||||
lora_freq: int
|
||||
modem_preset: str
|
||||
|
||||
|
||||
class PositionEvent(TypedDict, total=False):
|
||||
id: int
|
||||
node_id: str
|
||||
node_num: int | None
|
||||
num: int | None
|
||||
from_id: str | None
|
||||
to_id: object
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
altitude: float | None
|
||||
position_time: int | None
|
||||
location_source: str | None
|
||||
precision_bits: int | None
|
||||
sats_in_view: int | None
|
||||
pdop: float | None
|
||||
ground_speed: float | None
|
||||
ground_track: float | None
|
||||
snr: float | None
|
||||
rssi: int | None
|
||||
hop_limit: int | None
|
||||
bitfield: int | None
|
||||
payload_b64: str | None
|
||||
raw: dict
|
||||
ingestor: str | None
|
||||
lora_freq: int
|
||||
modem_preset: str
|
||||
|
||||
|
||||
class TelemetryEvent(TypedDict, total=False):
|
||||
id: int
|
||||
node_id: str | None
|
||||
node_num: int | None
|
||||
from_id: object
|
||||
to_id: object
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
telemetry_time: int | None
|
||||
channel: int
|
||||
portnum: str | None
|
||||
hop_limit: int | None
|
||||
snr: float | None
|
||||
rssi: int | None
|
||||
bitfield: int | None
|
||||
payload_b64: str
|
||||
ingestor: str | None
|
||||
lora_freq: int
|
||||
modem_preset: str
|
||||
|
||||
# Metric keys are intentionally open-ended; the Ruby side is permissive and
|
||||
# evolves over time.
|
||||
|
||||
|
||||
class NeighborEntry(TypedDict, total=False):
|
||||
neighbor_id: str
|
||||
neighbor_num: int | None
|
||||
snr: float | None
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
|
||||
|
||||
class NeighborsSnapshot(TypedDict, total=False):
|
||||
node_id: str
|
||||
node_num: int | None
|
||||
neighbors: list[NeighborEntry]
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
node_broadcast_interval_secs: int | None
|
||||
last_sent_by_id: str | None
|
||||
ingestor: str | None
|
||||
lora_freq: int
|
||||
modem_preset: str
|
||||
|
||||
|
||||
class TraceEvent(TypedDict, total=False):
|
||||
id: int | None
|
||||
request_id: int | None
|
||||
src: int | None
|
||||
dest: int | None
|
||||
rx_time: int
|
||||
rx_iso: str
|
||||
hops: list[int]
|
||||
rssi: int | None
|
||||
snr: float | None
|
||||
elapsed_ms: int | None
|
||||
ingestor: str | None
|
||||
lora_freq: int
|
||||
modem_preset: str
|
||||
|
||||
|
||||
class IngestorHeartbeat(TypedDict):
|
||||
node_id: str
|
||||
start_time: int
|
||||
last_seen_time: int
|
||||
version: str
|
||||
lora_freq: NotRequired[int]
|
||||
modem_preset: NotRequired[str]
|
||||
|
||||
|
||||
NodeUpsert = dict[str, dict]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IngestorHeartbeat",
|
||||
"MessageEvent",
|
||||
"NeighborEntry",
|
||||
"NeighborsSnapshot",
|
||||
"NodeUpsert",
|
||||
"PositionEvent",
|
||||
"TelemetryEvent",
|
||||
"TraceEvent",
|
||||
]
|
||||
|
||||
116
data/mesh_ingestor/node_identity.py
Normal file
116
data/mesh_ingestor/node_identity.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Node identity helpers shared across ingestor providers.
|
||||
|
||||
The web application keys nodes by a canonical textual identifier of the form
|
||||
``!%08x`` (lowercase hex). Both the Python collector and Ruby server accept
|
||||
several input forms (ints, ``0x`` hex strings, ``!`` hex strings, decimal
|
||||
strings). This module centralizes that normalization.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
_CANONICAL_PREFIX: Final[str] = "!"
|
||||
|
||||
|
||||
def canonical_node_id(value: object) -> str | None:
|
||||
"""Convert ``value`` into canonical ``!xxxxxxxx`` form.
|
||||
|
||||
Parameters:
|
||||
value: Node reference which may be an int, float, or string.
|
||||
|
||||
Returns:
|
||||
Canonical node id string or ``None`` when parsing fails.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
num = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if num < 0:
|
||||
return None
|
||||
return f"{_CANONICAL_PREFIX}{num & 0xFFFFFFFF:08x}"
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
if trimmed.startswith("^"):
|
||||
# Meshtastic special destinations like "^all" are not node ids; callers
|
||||
# that already accept them should keep passing them through unchanged.
|
||||
return trimmed
|
||||
if trimmed.startswith(_CANONICAL_PREFIX):
|
||||
body = trimmed[1:]
|
||||
elif trimmed.lower().startswith("0x"):
|
||||
body = trimmed[2:]
|
||||
elif trimmed.isdigit():
|
||||
try:
|
||||
return f"{_CANONICAL_PREFIX}{int(trimmed, 10) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
body = trimmed
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return f"{_CANONICAL_PREFIX}{int(body, 16) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def node_num_from_id(node_id: object) -> int | None:
|
||||
"""Extract the numeric node identifier from a canonical (or near-canonical) id."""
|
||||
|
||||
if node_id is None:
|
||||
return None
|
||||
if isinstance(node_id, (int, float)):
|
||||
try:
|
||||
num = int(node_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return num if num >= 0 else None
|
||||
if not isinstance(node_id, str):
|
||||
return None
|
||||
|
||||
trimmed = node_id.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
if trimmed.startswith(_CANONICAL_PREFIX):
|
||||
trimmed = trimmed[1:]
|
||||
if trimmed.lower().startswith("0x"):
|
||||
trimmed = trimmed[2:]
|
||||
try:
|
||||
return int(trimmed, 16)
|
||||
except ValueError:
|
||||
try:
|
||||
return int(trimmed, 10)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"canonical_node_id",
|
||||
"node_num_from_id",
|
||||
]
|
||||
|
||||
65
data/mesh_ingestor/provider.py
Normal file
65
data/mesh_ingestor/provider.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Provider interface for ingestion sources.
|
||||
|
||||
Today the repo ships a Meshtastic provider only. This module defines the seam so
|
||||
future providers (MeshCore, Reticulum, ...) can be added without changing the
|
||||
web app ingest contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from collections.abc import Iterable
|
||||
from typing import Protocol
|
||||
|
||||
class ProviderCapability(enum.Flag):
|
||||
"""Feature flags describing what a provider can supply."""
|
||||
|
||||
NONE = 0
|
||||
NODE_SNAPSHOT = enum.auto()
|
||||
HEARTBEATS = enum.auto()
|
||||
|
||||
|
||||
class Provider(Protocol):
|
||||
"""Abstract source of mesh observations."""
|
||||
|
||||
name: str
|
||||
capabilities: ProviderCapability
|
||||
|
||||
def subscribe(self) -> list[str]:
|
||||
"""Subscribe to any async receive callbacks and return topic names."""
|
||||
|
||||
def connect(
|
||||
self, *, active_candidate: str | None
|
||||
) -> tuple[object, str | None, str | None]:
|
||||
"""Create an interface connection.
|
||||
|
||||
Returns:
|
||||
(iface, resolved_target, next_active_candidate)
|
||||
"""
|
||||
|
||||
def extract_host_node_id(self, iface: object) -> str | None:
|
||||
"""Best-effort extraction of the connected host node id."""
|
||||
|
||||
def node_snapshot_items(self, iface: object) -> Iterable[tuple[str, object]]:
|
||||
"""Return iterable of (node_id, node_obj) for initial snapshot."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"ProviderCapability",
|
||||
]
|
||||
|
||||
26
data/mesh_ingestor/providers/__init__.py
Normal file
26
data/mesh_ingestor/providers/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Provider implementations.
|
||||
|
||||
This package contains protocol-specific provider implementations (Meshtastic
|
||||
today, others in the future).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .meshtastic import MeshtasticProvider
|
||||
|
||||
__all__ = ["MeshtasticProvider"]
|
||||
|
||||
84
data/mesh_ingestor/providers/meshtastic.py
Normal file
84
data/mesh_ingestor/providers/meshtastic.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Meshtastic provider implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from .. import interfaces
|
||||
from ..provider import ProviderCapability
|
||||
|
||||
|
||||
class MeshtasticProvider:
|
||||
"""Meshtastic ingestion provider (current default)."""
|
||||
|
||||
name = "meshtastic"
|
||||
capabilities = ProviderCapability.NODE_SNAPSHOT | ProviderCapability.HEARTBEATS
|
||||
|
||||
def __init__(self):
|
||||
self._subscribed: list[str] = []
|
||||
|
||||
def subscribe(self) -> list[str]:
|
||||
"""Subscribe Meshtastic pubsub receive topics."""
|
||||
|
||||
if self._subscribed:
|
||||
return list(self._subscribed)
|
||||
|
||||
# Delegate to the historical subscription helper in `daemon.py` so unit
|
||||
# tests can monkeypatch the subscription mechanism via `daemon.pub`.
|
||||
from .. import daemon as _daemon # local import avoids module cycles
|
||||
|
||||
topics = _daemon._subscribe_receive_topics()
|
||||
|
||||
self._subscribed = topics
|
||||
return list(topics)
|
||||
|
||||
def connect(
|
||||
self, *, active_candidate: str | None
|
||||
) -> tuple[object, str | None, str | None]:
|
||||
"""Create a Meshtastic interface using the existing interface helpers."""
|
||||
|
||||
iface = None
|
||||
resolved_target = None
|
||||
next_candidate = active_candidate
|
||||
|
||||
if active_candidate:
|
||||
iface, resolved_target = interfaces._create_serial_interface(active_candidate)
|
||||
else:
|
||||
iface, resolved_target = interfaces._create_default_interface()
|
||||
next_candidate = resolved_target
|
||||
|
||||
interfaces._ensure_radio_metadata(iface)
|
||||
interfaces._ensure_channel_metadata(iface)
|
||||
|
||||
return iface, resolved_target, next_candidate
|
||||
|
||||
def extract_host_node_id(self, iface: object) -> str | None:
|
||||
return interfaces._extract_host_node_id(iface)
|
||||
|
||||
def node_snapshot_items(self, iface: object) -> Iterable[tuple[str, object]]:
|
||||
nodes = getattr(iface, "nodes", {}) or {}
|
||||
items_callable = getattr(nodes, "items", None)
|
||||
if callable(items_callable):
|
||||
return list(items_callable())
|
||||
if hasattr(nodes, "__iter__") and hasattr(nodes, "__getitem__"):
|
||||
keys = list(nodes)
|
||||
return [(key, nodes[key]) for key in keys]
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["MeshtasticProvider"]
|
||||
|
||||
@@ -33,6 +33,9 @@ from google.protobuf.json_format import MessageToDict
|
||||
from google.protobuf.message import DecodeError
|
||||
from google.protobuf.message import Message as ProtoMessage
|
||||
|
||||
from .node_identity import canonical_node_id as _canonical_node_id
|
||||
from .node_identity import node_num_from_id as _node_num_from_id
|
||||
|
||||
_CLI_ROLE_MODULE_NAMES: tuple[str, ...] = (
|
||||
"meshtastic.cli.common",
|
||||
"meshtastic.cli.roles",
|
||||
@@ -429,91 +432,6 @@ def _pkt_to_dict(packet) -> dict:
|
||||
return {"_unparsed": str(packet)}
|
||||
|
||||
|
||||
def _canonical_node_id(value) -> str | None:
|
||||
"""Convert node identifiers into the canonical ``!xxxxxxxx`` format.
|
||||
|
||||
Parameters:
|
||||
value: Input identifier which may be an int, float or string.
|
||||
|
||||
Returns:
|
||||
The canonical identifier or ``None`` if conversion fails.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
num = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if num < 0:
|
||||
return None
|
||||
return f"!{num & 0xFFFFFFFF:08x}"
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
if trimmed.startswith("^"):
|
||||
return trimmed
|
||||
if trimmed.startswith("!"):
|
||||
body = trimmed[1:]
|
||||
elif trimmed.lower().startswith("0x"):
|
||||
body = trimmed[2:]
|
||||
elif trimmed.isdigit():
|
||||
try:
|
||||
return f"!{int(trimmed, 10) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
body = trimmed
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return f"!{int(body, 16) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _node_num_from_id(node_id) -> int | None:
|
||||
"""Extract the numeric node ID from a canonical identifier.
|
||||
|
||||
Parameters:
|
||||
node_id: Identifier value accepted by :func:`_canonical_node_id`.
|
||||
|
||||
Returns:
|
||||
The numeric node ID or ``None`` when parsing fails.
|
||||
"""
|
||||
|
||||
if node_id is None:
|
||||
return None
|
||||
if isinstance(node_id, (int, float)):
|
||||
try:
|
||||
num = int(node_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return num if num >= 0 else None
|
||||
if not isinstance(node_id, str):
|
||||
return None
|
||||
|
||||
trimmed = node_id.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
if trimmed.startswith("!"):
|
||||
trimmed = trimmed[1:]
|
||||
if trimmed.lower().startswith("0x"):
|
||||
trimmed = trimmed[2:]
|
||||
try:
|
||||
return int(trimmed, 16)
|
||||
except ValueError:
|
||||
try:
|
||||
return int(trimmed, 10)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_mappings(base, extra):
|
||||
"""Merge two mapping-like objects recursively.
|
||||
|
||||
|
||||
10
matrix/Cargo.lock
generated
10
matrix/Cargo.lock
generated
@@ -969,7 +969,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.10"
|
||||
version = "0.5.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1037,9 +1037,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@@ -1255,9 +1255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.10"
|
||||
version = "0.5.11"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -19,8 +19,10 @@ import io
|
||||
import json
|
||||
import sys
|
||||
|
||||
from meshtastic.protobuf import mesh_pb2
|
||||
from meshtastic.protobuf import telemetry_pb2
|
||||
import pytest
|
||||
|
||||
mesh_pb2 = pytest.importorskip("meshtastic.protobuf.mesh_pb2")
|
||||
telemetry_pb2 = pytest.importorskip("meshtastic.protobuf.telemetry_pb2")
|
||||
|
||||
from data.mesh_ingestor import decode_payload
|
||||
|
||||
|
||||
54
tests/test_node_identity_unit.py
Normal file
54
tests/test_node_identity_unit.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Unit tests for :mod:`data.mesh_ingestor.node_identity`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from data.mesh_ingestor.node_identity import ( # noqa: E402 - path setup
|
||||
canonical_node_id,
|
||||
node_num_from_id,
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_node_id_accepts_numeric():
|
||||
assert canonical_node_id(1) == "!00000001"
|
||||
assert canonical_node_id(0xABCDEF01) == "!abcdef01"
|
||||
assert canonical_node_id(1.0) == "!00000001"
|
||||
|
||||
|
||||
def test_canonical_node_id_accepts_string_forms():
|
||||
assert canonical_node_id("!ABCDEF01") == "!abcdef01"
|
||||
assert canonical_node_id("0xABCDEF01") == "!abcdef01"
|
||||
assert canonical_node_id("abcdef01") == "!abcdef01"
|
||||
assert canonical_node_id("123") == "!0000007b"
|
||||
|
||||
|
||||
def test_canonical_node_id_passthrough_caret_destinations():
|
||||
assert canonical_node_id("^all") == "^all"
|
||||
|
||||
|
||||
def test_node_num_from_id_parses_canonical_and_hex():
|
||||
assert node_num_from_id("!abcdef01") == 0xABCDEF01
|
||||
assert node_num_from_id("abcdef01") == 0xABCDEF01
|
||||
assert node_num_from_id("0xabcdef01") == 0xABCDEF01
|
||||
assert node_num_from_id(123) == 123
|
||||
|
||||
|
||||
101
tests/test_provider_unit.py
Normal file
101
tests/test_provider_unit.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Unit tests for :mod:`data.mesh_ingestor.provider` integration seams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from data.mesh_ingestor import daemon # noqa: E402 - path setup
|
||||
from data.mesh_ingestor.providers.meshtastic import ( # noqa: E402 - path setup
|
||||
MeshtasticProvider,
|
||||
)
|
||||
|
||||
|
||||
def test_daemon_main_uses_provider_connect(monkeypatch):
|
||||
calls = {"connect": 0}
|
||||
|
||||
class FakeProvider(MeshtasticProvider):
|
||||
def subscribe(self):
|
||||
return []
|
||||
|
||||
def connect(self, *, active_candidate): # type: ignore[override]
|
||||
calls["connect"] += 1
|
||||
# Return a minimal iface and stop immediately via Event.
|
||||
class Iface:
|
||||
nodes = {}
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
return Iface(), "serial0", active_candidate
|
||||
|
||||
def extract_host_node_id(self, iface): # type: ignore[override]
|
||||
return "!host"
|
||||
|
||||
def node_snapshot_items(self, iface): # type: ignore[override]
|
||||
return []
|
||||
|
||||
# Make the loop exit quickly.
|
||||
class AutoStopEvent:
|
||||
def __init__(self):
|
||||
self._set = False
|
||||
|
||||
def set(self):
|
||||
self._set = True
|
||||
|
||||
def is_set(self):
|
||||
return self._set
|
||||
|
||||
def wait(self, _timeout=None):
|
||||
self._set = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", False)
|
||||
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
|
||||
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoStopEvent,
|
||||
current_thread=daemon.threading.current_thread,
|
||||
main_thread=daemon.threading.main_thread,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(daemon.handlers, "register_host_node_id", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
||||
monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(daemon.ingestors, "queue_ingestor_heartbeat", lambda *_a, **_k: True)
|
||||
|
||||
daemon.main(provider=FakeProvider())
|
||||
assert calls["connect"] >= 1
|
||||
|
||||
@@ -55,8 +55,38 @@ def _javascript_package_version() -> str:
|
||||
raise AssertionError("package.json does not expose a string version")
|
||||
|
||||
|
||||
def _flutter_package_version() -> str:
|
||||
pubspec_path = REPO_ROOT / "app" / "pubspec.yaml"
|
||||
for line in pubspec_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("version:"):
|
||||
version = line.split(":", 1)[1].strip()
|
||||
if version:
|
||||
return version
|
||||
break
|
||||
raise AssertionError("pubspec.yaml does not expose a version")
|
||||
|
||||
|
||||
def _rust_package_version() -> str:
|
||||
cargo_path = REPO_ROOT / "matrix" / "Cargo.toml"
|
||||
inside_package = False
|
||||
for line in cargo_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped == "[package]":
|
||||
inside_package = True
|
||||
continue
|
||||
if inside_package and stripped.startswith("[") and stripped.endswith("]"):
|
||||
break
|
||||
if inside_package:
|
||||
literal = re.match(
|
||||
r'version\s*=\s*["\'](?P<version>[^"\']+)["\']', stripped
|
||||
)
|
||||
if literal:
|
||||
return literal.group("version")
|
||||
raise AssertionError("Cargo.toml does not expose a package version")
|
||||
|
||||
|
||||
def test_version_identifiers_match_across_languages() -> None:
|
||||
"""Guard against version drift between Python, Ruby, and JavaScript."""
|
||||
"""Guard against version drift between Python, Ruby, JavaScript, Flutter, and Rust."""
|
||||
|
||||
python_version = getattr(data, "__version__", None)
|
||||
assert (
|
||||
@@ -65,5 +95,13 @@ def test_version_identifiers_match_across_languages() -> None:
|
||||
|
||||
ruby_version = _ruby_fallback_version()
|
||||
javascript_version = _javascript_package_version()
|
||||
flutter_version = _flutter_package_version()
|
||||
rust_version = _rust_package_version()
|
||||
|
||||
assert python_version == ruby_version == javascript_version
|
||||
assert (
|
||||
python_version
|
||||
== ruby_version
|
||||
== javascript_version
|
||||
== flutter_version
|
||||
== rust_version
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"0.5.10"
|
||||
"0.5.11"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
isSuppressedFederationSiteName,
|
||||
resolveFederationInstanceLabel,
|
||||
resolveFederationInstanceSortValue,
|
||||
resolveFederationSiteNameForDisplay,
|
||||
shouldDisplayFederationInstance,
|
||||
truncateFederationSiteName
|
||||
} from '../federation-instance-display.js';
|
||||
|
||||
test('isSuppressedFederationSiteName detects URL-like advertising names', () => {
|
||||
assert.equal(isSuppressedFederationSiteName('http://spam.example offer'), true);
|
||||
assert.equal(isSuppressedFederationSiteName('Visit www.spam.example today'), true);
|
||||
assert.equal(isSuppressedFederationSiteName('Mesh Collective'), false);
|
||||
assert.equal(isSuppressedFederationSiteName(''), false);
|
||||
assert.equal(isSuppressedFederationSiteName(null), false);
|
||||
});
|
||||
|
||||
test('truncateFederationSiteName shortens names longer than 32 characters', () => {
|
||||
assert.equal(truncateFederationSiteName('Short Mesh'), 'Short Mesh');
|
||||
assert.equal(
|
||||
truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz1234567890'),
|
||||
'abcdefghijklmnopqrstuvwxyz123...'
|
||||
);
|
||||
assert.equal(truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz123456').length, 32);
|
||||
assert.equal(truncateFederationSiteName(null), '');
|
||||
});
|
||||
|
||||
test('display helpers filter suppressed names and preserve original domains', () => {
|
||||
const entries = [
|
||||
{ name: 'Normal Mesh', domain: 'normal.mesh' },
|
||||
{ name: 'https://spam.example promo', domain: 'spam.mesh' },
|
||||
{ domain: 'unnamed.mesh' }
|
||||
];
|
||||
|
||||
assert.equal(shouldDisplayFederationInstance(entries[0]), true);
|
||||
assert.equal(shouldDisplayFederationInstance(entries[1]), false);
|
||||
assert.deepEqual(filterDisplayableFederationInstances(entries), [
|
||||
{ name: 'Normal Mesh', domain: 'normal.mesh' },
|
||||
{ domain: 'unnamed.mesh' }
|
||||
]);
|
||||
assert.equal(resolveFederationSiteNameForDisplay(entries[0]), 'Normal Mesh');
|
||||
assert.equal(resolveFederationInstanceLabel(entries[2]), 'unnamed.mesh');
|
||||
assert.equal(resolveFederationInstanceSortValue(entries[0]), 'Normal Mesh');
|
||||
});
|
||||
@@ -21,6 +21,74 @@ import { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeFederationPage } from '../federation-page.js';
|
||||
import { roleColors } from '../role-helpers.js';
|
||||
|
||||
function createBasicFederationPageHarness() {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement } = env;
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
tableEl.appendChild(tbodyEl);
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { ...env, statusEl, tbodyEl };
|
||||
}
|
||||
|
||||
function createBasicLeafletStub(options = {}) {
|
||||
const { markerPopups = null, fitBounds = false } = options;
|
||||
|
||||
return {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
fitBounds: fitBounds ? () => {} : undefined,
|
||||
getPane() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return {
|
||||
addLayer() {},
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
},
|
||||
circleMarker() {
|
||||
return {
|
||||
bindPopup(html) {
|
||||
markerPopups?.push(html);
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('federation map centers on configured coordinates and follows theme filters', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true });
|
||||
const { document, window, createElement, registerElement, cleanup } = env;
|
||||
@@ -603,57 +671,141 @@ test('federation legend toggle respects media query changes', async () => {
|
||||
});
|
||||
|
||||
test('federation page tolerates fetch failures', async () => {
|
||||
const { cleanup } = createBasicFederationPageHarness();
|
||||
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
|
||||
const leafletStub = createBasicLeafletStub();
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('federation page suppresses spammy site names and truncates long names in visible UI', async () => {
|
||||
const { cleanup, statusEl, tbodyEl } = createBasicFederationPageHarness();
|
||||
const markerPopups = [];
|
||||
const leafletStub = createBasicLeafletStub({ markerPopups, fitBounds: true });
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'visible.mesh',
|
||||
name: 'abcdefghijklmnopqrstuvwxyz1234567890',
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
|
||||
},
|
||||
{
|
||||
domain: 'spam.mesh',
|
||||
name: 'www.spam.example buy now',
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
|
||||
assert.equal(statusEl.textContent, '1 instances');
|
||||
assert.equal(tbodyEl.childNodes.length, 1);
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.doesNotMatch(tbodyEl.childNodes[0].innerHTML, /spam\.mesh/);
|
||||
assert.equal(markerPopups.length, 1);
|
||||
assert.match(markerPopups[0], /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.doesNotMatch(markerPopups[0], /www\.spam\.example/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation page sorts by full site names before truncating visible labels', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement, cleanup } = env;
|
||||
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
tableEl.appendChild(tbodyEl);
|
||||
|
||||
const headerNameTh = createElement('th');
|
||||
const headerName = createElement('span');
|
||||
headerName.classList.add('sort-header');
|
||||
headerName.dataset.sortKey = 'name';
|
||||
headerName.dataset.sortLabel = 'Name';
|
||||
headerNameTh.appendChild(headerName);
|
||||
|
||||
const ths = [headerNameTh];
|
||||
const headers = [headerName];
|
||||
const headerHandlers = new Map();
|
||||
headers.forEach(header => {
|
||||
header.addEventListener = (event, handler) => {
|
||||
const existing = headerHandlers.get(header) || {};
|
||||
existing[event] = handler;
|
||||
headerHandlers.set(header, existing);
|
||||
};
|
||||
header.closest = () => ths.find(th => th.childNodes.includes(header));
|
||||
header.querySelector = () => null;
|
||||
});
|
||||
|
||||
tableEl.querySelectorAll = selector => {
|
||||
if (selector === 'thead .sort-header[data-sort-key]') return headers;
|
||||
if (selector === 'thead th') return ths;
|
||||
return [];
|
||||
};
|
||||
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({}));
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
const leafletStub = {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
getPane() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return { addLayer() {}, addTo() { return this; } };
|
||||
},
|
||||
circleMarker() {
|
||||
return { bindPopup() { return this; } };
|
||||
}
|
||||
};
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'zeta.mesh',
|
||||
name: `${sharedPrefix}zeta suffix`,
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
|
||||
},
|
||||
{
|
||||
domain: 'alpha.mesh',
|
||||
name: `${sharedPrefix}alpha suffix`,
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
try {
|
||||
await initializeFederationPage({
|
||||
config: {},
|
||||
fetchImpl,
|
||||
leaflet: createBasicLeafletStub({ fitBounds: true })
|
||||
});
|
||||
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
cleanup();
|
||||
const nameHandlers = headerHandlers.get(headerName);
|
||||
nameHandlers.click();
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /alpha\.mesh/);
|
||||
assert.match(tbodyEl.childNodes[1].innerHTML, /zeta\.mesh/);
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.match(tbodyEl.childNodes[1].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +154,75 @@ test('initializeInstanceSelector populates options alphabetically and selects th
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector hides suppressed names and truncates long labels', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
const navLink = env.document.createElement('a');
|
||||
navLink.classList.add('js-federation-nav');
|
||||
navLink.textContent = 'Federation';
|
||||
env.document.body.appendChild(navLink);
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [
|
||||
{ name: 'Visit https://spam.example now', domain: 'spam.mesh' },
|
||||
{ name: 'abcdefghijklmnopqrstuvwxyz1234567890', domain: 'long.mesh' },
|
||||
{ name: 'Alpha Mesh', domain: 'alpha.mesh' }
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.equal(select.options.length, 3);
|
||||
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
assert.equal(select.options[2].textContent, 'Alpha Mesh');
|
||||
assert.equal(navLink.textContent, 'Federation (2)');
|
||||
assert.equal(select.options.some(option => option.value === 'spam.mesh'), false);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector sorts by full site names before truncating labels', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [
|
||||
{ name: `${sharedPrefix}zeta suffix`, domain: 'zeta.mesh' },
|
||||
{ name: `${sharedPrefix}alpha suffix`, domain: 'alpha.mesh' }
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.equal(select.options[1].value, 'alpha.mesh');
|
||||
assert.equal(select.options[2].value, 'zeta.mesh');
|
||||
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
assert.equal(select.options[2].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
|
||||
172
web/public/assets/js/app/federation-instance-display.js
Normal file
172
web/public/assets/js/app/federation-instance-display.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const MAX_VISIBLE_SITE_NAME_LENGTH = 32;
|
||||
const TRUNCATION_SUFFIX = '...';
|
||||
const TRUNCATED_SITE_NAME_LENGTH = MAX_VISIBLE_SITE_NAME_LENGTH - TRUNCATION_SUFFIX.length;
|
||||
const SUPPRESSED_SITE_NAME_PATTERN = /(?:^|[^a-z0-9])(?:https?:\/\/|www\.)\S+/i;
|
||||
|
||||
/**
|
||||
* Read a federated instance site name as a trimmed string.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Trimmed site name or an empty string when absent.
|
||||
*/
|
||||
function readSiteName(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a federated instance domain as a trimmed string.
|
||||
*
|
||||
* @param {{ domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Trimmed domain or an empty string when absent.
|
||||
*/
|
||||
function readDomain(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return typeof entry.domain === 'string' ? entry.domain.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a remote site name should be suppressed from frontend displays.
|
||||
*
|
||||
* @param {string} name Remote site name.
|
||||
* @returns {boolean} true when the name contains a URL-like advertising token.
|
||||
*/
|
||||
export function isSuppressedFederationSiteName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SUPPRESSED_SITE_NAME_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate an instance site name for frontend display without mutating source data.
|
||||
*
|
||||
* Names longer than 32 characters are shortened to stay within that 32-character
|
||||
* budget including the trailing ellipsis.
|
||||
*
|
||||
* @param {string} name Remote site name.
|
||||
* @returns {string} Display-ready site name.
|
||||
*/
|
||||
export function truncateFederationSiteName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length <= MAX_VISIBLE_SITE_NAME_LENGTH) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, TRUNCATED_SITE_NAME_LENGTH)}${TRUNCATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an instance should remain visible in frontend federation views.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {boolean} true when the entry should be shown to users.
|
||||
*/
|
||||
export function shouldDisplayFederationInstance(entry) {
|
||||
return !isSuppressedFederationSiteName(readSiteName(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a frontend display name for a federation instance.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Display-ready site name or an empty string when absent.
|
||||
*/
|
||||
export function resolveFederationSiteNameForDisplay(entry) {
|
||||
const siteName = readSiteName(entry);
|
||||
return siteName ? truncateFederationSiteName(siteName) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original trimmed site name for a federation instance.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Full trimmed site name or an empty string when absent.
|
||||
*/
|
||||
export function resolveFederationSiteName(entry) {
|
||||
return readSiteName(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the full sort value for an instance selector entry.
|
||||
*
|
||||
* Sorting must use the original trimmed site name so truncation does not collapse
|
||||
* multiple entries into the same comparison key.
|
||||
*
|
||||
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Full trimmed site name falling back to the domain.
|
||||
*/
|
||||
export function resolveFederationInstanceSortValue(entry) {
|
||||
const siteName = resolveFederationSiteName(entry);
|
||||
return siteName || readDomain(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the most suitable display label for an instance list entry.
|
||||
*
|
||||
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Display label falling back to the domain.
|
||||
*/
|
||||
export function resolveFederationInstanceLabel(entry) {
|
||||
const siteName = resolveFederationSiteNameForDisplay(entry);
|
||||
if (siteName) {
|
||||
return siteName;
|
||||
}
|
||||
|
||||
return readDomain(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a federation payload down to the instances that should remain visible.
|
||||
*
|
||||
* @param {Array<object>} entries Federation payload from the API.
|
||||
* @returns {Array<object>} Visible instances for frontend rendering.
|
||||
*/
|
||||
export function filterDisplayableFederationInstances(entries) {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries.filter(shouldDisplayFederationInstance);
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
MAX_VISIBLE_SITE_NAME_LENGTH,
|
||||
TRUNCATION_SUFFIX,
|
||||
TRUNCATED_SITE_NAME_LENGTH,
|
||||
readDomain,
|
||||
readSiteName,
|
||||
SUPPRESSED_SITE_NAME_PATTERN
|
||||
};
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
|
||||
import { readAppConfig } from './config.js';
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
resolveFederationSiteName,
|
||||
resolveFederationSiteNameForDisplay
|
||||
} from './federation-instance-display.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { mergeConfig } from './settings.js';
|
||||
import { roleColors } from './role-helpers.js';
|
||||
@@ -274,7 +279,12 @@ export async function initializeFederationPage(options = {}) {
|
||||
? true
|
||||
: legendCollapsedValue.trim() !== 'false';
|
||||
const tableSorters = {
|
||||
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
name: {
|
||||
getValue: inst => resolveFederationSiteName(inst),
|
||||
compare: compareString,
|
||||
hasValue: hasStringValue,
|
||||
defaultDirection: 'asc'
|
||||
},
|
||||
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
@@ -363,7 +373,8 @@ export async function initializeFederationPage(options = {}) {
|
||||
for (const instance of sorted) {
|
||||
const tr = document.createElement('tr');
|
||||
const url = buildInstanceUrl(instance.domain);
|
||||
const nameHtml = instance.name ? escapeHtml(instance.name) : '<em>—</em>';
|
||||
const displayName = resolveFederationSiteNameForDisplay(instance);
|
||||
const nameHtml = displayName ? escapeHtml(displayName) : '<em>—</em>';
|
||||
const domainHtml = url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
|
||||
: escapeHtml(instance.domain || '');
|
||||
@@ -529,7 +540,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
credentials: 'omit'
|
||||
});
|
||||
if (response.ok) {
|
||||
instances = await response.json();
|
||||
instances = filterDisplayableFederationInstances(await response.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch federation instances', err);
|
||||
@@ -636,7 +647,8 @@ export async function initializeFederationPage(options = {}) {
|
||||
|
||||
bounds.push([lat, lon]);
|
||||
|
||||
const name = instance.name || instance.domain || 'Unknown';
|
||||
const displayName = resolveFederationSiteNameForDisplay(instance);
|
||||
const name = displayName || instance.domain || 'Unknown';
|
||||
const url = buildInstanceUrl(instance.domain);
|
||||
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
|
||||
const popupLines = [
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
resolveFederationInstanceLabel,
|
||||
resolveFederationInstanceSortValue
|
||||
} from './federation-instance-display.js';
|
||||
|
||||
/**
|
||||
* Determine the most suitable label for an instance list entry.
|
||||
*
|
||||
@@ -21,17 +27,7 @@
|
||||
* @returns {string} Preferred display label falling back to the domain.
|
||||
*/
|
||||
function resolveInstanceLabel(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
if (name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
|
||||
return domain;
|
||||
return resolveFederationInstanceLabel(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,23 +202,21 @@ export async function initializeInstanceSelector(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFederationNavCount({ documentObject: doc, count: payload.length });
|
||||
const visibleEntries = filterDisplayableFederationInstances(payload);
|
||||
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
|
||||
|
||||
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
|
||||
|
||||
const sortedEntries = payload
|
||||
const sortedEntries = visibleEntries
|
||||
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
|
||||
.map(entry => ({
|
||||
domain: entry.domain.trim(),
|
||||
label: resolveInstanceLabel(entry),
|
||||
sortLabel: resolveFederationInstanceSortValue(entry),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const labelA = a.label || a.domain;
|
||||
const labelB = b.label || b.domain;
|
||||
const labelA = a.sortLabel || a.domain;
|
||||
const labelB = b.sortLabel || b.domain;
|
||||
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
node_id: "!abc12345",
|
||||
start_time: now - 120,
|
||||
last_seen_time: now - 60,
|
||||
version: "0.5.10",
|
||||
version: "0.5.11",
|
||||
lora_freq: 915,
|
||||
modem_preset: "LongFast",
|
||||
}.merge(overrides)
|
||||
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!fresh000", now - 100, now - 10, "0.5.10"],
|
||||
["!fresh000", now - 100, now - 10, "0.5.11"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
|
||||
["!rich000", now - 200, now - 100, "0.5.10", 915, "MediumFast"],
|
||||
["!rich000", now - 200, now - 100, "0.5.11", 915, "MediumFast"],
|
||||
)
|
||||
end
|
||||
|
||||
@@ -173,7 +173,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!new-ingestor", now - 60, now - 30, "0.5.10"],
|
||||
["!new-ingestor", now - 60, now - 30, "0.5.11"],
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user