Compare commits

..

22 Commits

Author SHA1 Message Date
l5y 3daadc4f68 handle naming when primary channel has a name (#422) 2025-11-08 09:44:41 +01:00
l5y 6b72b1b3da handle edge case when primary channel has a name (#421) 2025-11-07 21:39:26 +01:00
l5y 52486d82ad Add preset mode to logs (#420) 2025-11-07 17:56:27 +01:00
l5y 487d618e00 Parallelize federation tasks with worker pool (#419)
* Parallelize federation work with worker pool

* Handle worker pool shutdown fallback during federation announcements
2025-11-07 17:24:37 +01:00
l5y 9239805129 allow filtering chat and logs by node name (#417) 2025-11-07 15:55:11 +01:00
l5y 554b2abd82 gem: add erb as dependency removed from std (#416)
* gem: add erb as dependency removed from std

* Relax erb dependency for Ruby 3.3 compatibility
2025-11-07 15:11:05 +01:00
l5y 8bb98f65d6 implement support for replies and reactions app (#411)
* implement support for replies and reactions app

* Allow numeric reaction port packets

* allow reaction packets through mai channel filter
2025-11-06 20:58:35 +01:00
l5y 71c0f8b21e ingestor: ignore direct messages on default channel (#414)
* ingestor: ignore direct messages on default channel

* tests: run black formatter
2025-11-06 20:14:32 +01:00
l5y aa2bc68544 agents: add instructions (#410) 2025-11-03 22:23:20 +00:00
l5y a8394effdc display encrypted messages in frontend log window (#409)
* display encrypted messages in frontend log window

* render recipient by known node name short id
2025-11-03 22:51:20 +01:00
l5y e27d5ab53c Add chat log entries for telemetry, position, and neighbor events (#408)
* Add telemetry and neighbor chat log events

* Refine chat log highlights for telemetry and position updates

* Add emoji prefixes to chat log events

* Fix telemetry highlights and emoji styling

* Remove italic chat copy and drop zero-valued highlights

* address style and formatting issues
2025-11-03 12:33:02 +01:00
l5y 6af272c01f Handle missing instance domain outside production (#405) 2025-10-31 12:36:53 +01:00
l5y 03e2fe6a72 Add tabbed chat panel with channel grouping (#404)
* feat: add tabbed chat panel with channel grouping

* Handle ISO-only chat timestamps in dashboard renderer

* Remove redundant chat channel tag
2025-10-31 12:24:17 +01:00
l5y 87b4cd79e7 Normalize numeric client roles using Meshtastic CLI enums (#402)
* Normalize firmware client roles using CLI enums

* Prioritize CLI role lookup before protobuf fallbacks
2025-10-31 11:43:48 +01:00
l5y d94d75e605 Ensure Docker images publish versioned tags (#403) 2025-10-31 11:43:30 +01:00
l5y c965d05229 Document environment configuration variables (#400)
* Document environment configuration variables

* Escape sed replacements when updating .env values
2025-10-31 11:08:06 +01:00
l5y ba80fac36c Document federation refresh cadence (#401) 2025-10-31 11:05:08 +01:00
l5y 3c2c7611ee docs: document prometheus metrics (#399) 2025-10-31 11:04:20 +01:00
Nic Jansma 49e0f39ca9 Config: Read PROM_REPORT_IDS from environment (#398) 2025-10-29 09:22:33 +01:00
KenADev 625df7982d feat: Mesh-Ingestor: Ability to provide already-existing interface instance (#395)
* feat: Mesh-Ingestor: Ability to provide already-existing interface instance

* Prevent Signal-Registration if not main thread (causes exception)

* fix redundant ternary operator

---------

Co-authored-by: Ken Ahr <ken.a.iphone@googlemail.com>
2025-10-26 20:47:23 +01:00
KenADev 8eeb13166b fix: Ingestor: Fix error for non-existing datetime.UTC reference (#396)
Co-authored-by: Ken Ahr <ken.a.iphone@googlemail.com>
2025-10-26 20:46:31 +01:00
l5y 80645990cb Chore: bump version to 0.5.4 (#388)
Co-authored-by: l5yth <d220195275+l5yth@users.noreply.github.com>
2025-10-19 10:36:09 +00:00
47 changed files with 4871 additions and 158 deletions
+3
View File
@@ -71,6 +71,9 @@ INSTANCE_DOMAIN=mesh.example.org
# Docker image architecture (linux-amd64, linux-arm64, linux-armv7)
POTATOMESH_IMAGE_ARCH=linux-amd64
# Docker image tag (use "latest" for the newest release or pin to vX.Y)
POTATOMESH_IMAGE_TAG=latest
# Docker Compose networking profile
# Leave unset for Linux hosts (default host networking).
# Set to "bridge" on Docker Desktop (macOS/Windows) if host networking
+16 -6
View File
@@ -56,12 +56,17 @@ jobs:
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
RAW_VERSION="${{ github.event.inputs.version }}"
else
VERSION=${GITHUB_REF#refs/tags/v}
RAW_VERSION=${GITHUB_REF#refs/tags/}
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Published version: $VERSION"
STRIPPED_VERSION=${RAW_VERSION#v}
echo "version=$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "version_with_v=v$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "raw_version=$RAW_VERSION" >> $GITHUB_OUTPUT
echo "Published version: $STRIPPED_VERSION"
- name: Build and push ${{ matrix.service }} for ${{ matrix.architecture.name }}
uses: docker/build-push-action@v5
@@ -74,6 +79,7 @@ jobs:
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:${{ steps.version.outputs.version_with_v }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || 'Python Ingestor' }} for ${{ matrix.architecture.label }}
@@ -111,12 +117,15 @@ jobs:
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
RAW_VERSION=${GITHUB_REF#refs/tags/}
STRIPPED_VERSION=${RAW_VERSION#v}
echo "version=$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "version_with_v=v$STRIPPED_VERSION" >> $GITHUB_OUTPUT
- name: Test web application (Linux AMD64)
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:${{ steps.version.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:${{ steps.version.outputs.version_with_v }}
docker run --rm -d --name web-test -p 41447:41447 \
-e API_TOKEN=test-token \
-e DEBUG=1 \
@@ -128,6 +137,7 @@ jobs:
- name: Test ingestor (Linux AMD64)
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version_with_v }}
docker run --rm --name ingestor-test \
-e POTATOMESH_INSTANCE=http://localhost:41447 \
-e API_TOKEN=test-token \
+4
View File
@@ -69,3 +69,7 @@ ai_docs/
# Generated credentials for the instance
web/.config
# JavaScript dependencies
node_modules/
web/node_modules/
+39
View File
@@ -0,0 +1,39 @@
# Repository Guidelines
Keep code well structured, modular, and not monolithic. If modules get to big, consider submodules structure.
Make sure all tests pass for Python (`pytest`), Ruby (`rspec`), and JavaScript (`npm test`).
Make sure all code is properly inline documented (PDoc, RDoc, JSDoc, et.c). We do not want any undocumented code.
Make sure all code is 100% unit tested. We want all lines, units, and branches to be thouroughly covered by tests.
New source files should have Apache v2 license headers.
Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code formatting.
## Project Structure & Module Organization
The repository splits runtime and ingestion logic. `web/` holds the Sinatra dashboard (Ruby code in `lib/potato_mesh`, views in `views/`, static bundles in `public/`).
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
## Build, Test, and Development Commands
Run dependency installs inside `web/`: `bundle install` for gems and `npm ci` for JavaScript tooling. Start the app with `cd web && API_TOKEN=dev ./app.sh` for local work or `bundle exec rackup -p 41447` when integrating elsewhere.
Prep ingestion with `python -m venv .venv && pip install -r data/requirements.txt`; `./data/mesh.sh` streams from live radios. `docker-compose -f docker-compose.dev.yml up` brings up the full stack.
## Coding Style & Naming Conventions
Use two-space indentation for Ruby and keep `# frozen_string_literal: true` at the top of new files. Keep Ruby classes/modules in `CamelCase`, filenames in `snake_case.rb`, and feature specs in `*_spec.rb`.
JavaScript follows ES modules under `public/assets/js`; co-locate components with `__tests__` folders and use kebab-case filenames. Format Ruby via `bundle exec rufo .` and Python via `black`. Skip committing generated coverage artifacts.
## Testing Guidelines
Ruby specs run with `cd web && bundle exec rspec`, producing SimpleCov output in `coverage/`. Front-end behaviour is verified through Nodes test runner: `cd web && npm test` writes V8 coverage and JUnit XML under `reports/`.
The ingestion layer is guarded by `pytest -q tests/test_mesh.py`; leave fixtures in `tests/` untouched so CI can replay them. New features should ship with matching specs and updated integration checks.
## Commit & Pull Request Guidelines
Commits should stay imperative and reference issues the way history does (`Add chat log entries... (#408)`). Squash noisy work-in-progress commits before pushing. Pull requests need a concise summary, screenshots or curl traces for UI/API tweaks, and links to tracked issues. Paste the command output for the test suites you ran and mention configuration toggles (`API_TOKEN`, `PRIVATE`) reviewers must set.
## Security & Configuration Tips
Never commit real API tokens or `.sqlite` dumps; use `.env.local` files ignored by Git. Confirm env defaults (`API_TOKEN`, `INSTANCE_DOMAIN`, `PRIVATE`) before deploying, and set `FEDERATION=0` when staging private nodes. Review `PROMETHEUS.md` when exposing metrics so scrape endpoints stay internal.
+17
View File
@@ -1,5 +1,22 @@
# CHANGELOG
## v0.5.3
* Add telemetry formatting utilities and extend node overlay by @l5yth in <https://github.com/l5yth/potato-mesh/pull/387>
* Prune blank values from API responses by @l5yth in <https://github.com/l5yth/potato-mesh/pull/386>
* Add full support to telemetry schema and API by @l5yth in <https://github.com/l5yth/potato-mesh/pull/385>
* Respect PORT environment override by @l5yth in <https://github.com/l5yth/potato-mesh/pull/384>
* Add instance selector dropdown for federation deployments by @l5yth in <https://github.com/l5yth/potato-mesh/pull/382>
* Harden federation announcements by @l5yth in <https://github.com/l5yth/potato-mesh/pull/381>
* Ensure private mode disables federation by @l5yth in <https://github.com/l5yth/potato-mesh/pull/380>
* Ensure private mode disables chat messaging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/378>
* Disable federation features when FEDERATION=0 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/379>
* Expose PRIVATE environment configuration across tooling by @l5yth in <https://github.com/l5yth/potato-mesh/pull/377>
* Fix frontend coverage export for Codecov by @l5yth in <https://github.com/l5yth/potato-mesh/pull/376>
* Restrict /api/instances results to recent records by @l5yth in <https://github.com/l5yth/potato-mesh/pull/374>
* Expose FEDERATION environment option across tooling by @l5yth in <https://github.com/l5yth/potato-mesh/pull/375>
* Chore: bump version to 0.5.3 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/372>
## v0.5.2
* Align theme and info controls by @l5yth in <https://github.com/l5yth/potato-mesh/pull/371>
+26 -17
View File
@@ -13,13 +13,15 @@ will pull the latest release images for you.
## Images on GHCR
| Service | Image |
|----------|-------------------------------------------------------------------|
| Web UI | `ghcr.io/l5yth/potato-mesh-web-linux-amd64:latest` |
| Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:latest` |
| Service | Image |
|----------|---------------------------------------------------------------------------------------------------------------|
| Web UI | `ghcr.io/l5yth/potato-mesh-web-linux-amd64:<tag>` (e.g. `latest`, `3.0`, or `v3.0`) |
| Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:<tag>` (e.g. `latest`, `3.0`, or `v3.0`) |
Images are published for every tagged release. Replace `latest` with a
specific version tag if you prefer pinned deployments.
Images are published for every tagged release. Each build receives both semantic
version tags (for example `3.0`) and a matching `v`-prefixed tag (for example
`v3.0`). `latest` always points to the newest release, so pin one of the version
tags when you need a specific build.
## Configure environment
@@ -36,17 +38,24 @@ INSTANCE_DOMAIN=mesh.example.org
Additional environment variables are optional:
- `CHANNEL`, `FREQUENCY`, `MAP_CENTER`, `MAX_DISTANCE`, and `CONTACT_LINK`
customise the UI.
- `POTATOMESH_INSTANCE` (defaults to `http://web:41447`) lets the ingestor post
to a remote PotatoMesh instance if you do not run both services together.
- `CONNECTION` overrides the default serial device or network endpoint used by
the ingestor.
- `CHANNEL_INDEX` selects the LoRa channel when using serial or Bluetooth
connections.
- `INSTANCE_DOMAIN` pins the public hostname advertised by the web UI and API
responses, bypassing reverse DNS detection when set.
- `DEBUG` enables verbose logging across the stack.
| Variable | Default | Purpose |
| --- | --- | --- |
| `API_TOKEN` | _required_ | Shared secret used by the ingestor and API clients for authenticated `POST` requests. |
| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) advertised by the web UI, metadata, and API responses. |
| `SITE_NAME` | `"PotatoMesh Demo"` | Title and branding surfaced in the web UI. |
| `CHANNEL` | `"#LongFast"` | Default LoRa channel label displayed on the dashboard. |
| `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix room alias rendered in UI footers and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map view. |
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. |
The ingestor also respects supporting variables such as `POTATOMESH_INSTANCE`
(defaults to `http://web:41447`) for remote posting and `CHANNEL_INDEX` when
selecting a LoRa channel on serial or Bluetooth connections.
## Docker Compose file
+100
View File
@@ -0,0 +1,100 @@
# Prometheus Monitoring for PotatoMesh
PotatoMesh exposes runtime telemetry through a dedicated Prometheus endpoint so you can
observe message flow, node health, and geospatial metadata alongside the rest of your
infrastructure. This guide explains how the exporter is wired into the web
application, which metrics are available, and how to integrate the endpoint with a
Prometheus server.
## Runtime integration
The Sinatra application automatically loads the `prometheus-client` gem and mounts the
collector and exporter middlewares during boot. No additional configuration is
required to enable the `/metrics` endpoint—running the web application is enough to
serve Prometheus data on the same port as the dashboard. The middleware pair both
collects default Rack statistics and publishes PotatoMesh-specific gauges and
counters that are updated whenever the ingestors process new node records.
A background refresh is triggered during start-up via
`update_all_prometheus_metrics_from_nodes`, which seeds the gauges based on the latest
state in the database. Subsequent POST requests to the ingest APIs update each metric
in near real time.
## Selecting which nodes are exported
To avoid creating high-cardinality time series, PotatoMesh does not export per-node
metrics unless you opt in by providing node identifiers. Control this behaviour with
the `PROM_REPORT_IDS` environment variable:
- Leave the variable unset or blank to only export aggregate gauges such as the total
node count.
- Set `PROM_REPORT_IDS=*` to export metrics for every node in the database.
- Provide a comma-separated list (for example `PROM_REPORT_IDS=ABCD1234,EFGH5678`) to
expose metrics for specific nodes.
The selection applies to both the initial refresh and the incremental updates handled
by the ingest pipeline.
## Available metrics
| Metric name | Type | Labels | Description |
| --- | --- | --- | --- |
| `meshtastic_messages_total` | Counter | _none_ | Increments each time the ingest pipeline accepts a new message payload. |
| `meshtastic_nodes` | Gauge | _none_ | Tracks the number of nodes currently stored in the database. |
| `meshtastic_node` | Gauge | `node`, `short_name`, `long_name`, `hw_model`, `role` | Reports a node as present (value `1`) along with identity metadata. |
| `meshtastic_node_battery_level` | Gauge | `node` | Most recent battery percentage reported by the node. |
| `meshtastic_node_voltage` | Gauge | `node` | Most recent battery voltage reading. |
| `meshtastic_node_uptime_seconds` | Gauge | `node` | Uptime reported by the device in seconds. |
| `meshtastic_node_channel_utilization` | Gauge | `node` | Latest channel utilisation ratio supplied by the node. |
| `meshtastic_node_transmit_air_utilization` | Gauge | `node` | Proportion of on-air time spent transmitting. |
| `meshtastic_node_latitude` | Gauge | `node` | Latitude component of the last known position. |
| `meshtastic_node_longitude` | Gauge | `node` | Longitude component of the last known position. |
| `meshtastic_node_altitude` | Gauge | `node` | Altitude (in metres) of the last known position. |
All per-node gauges are only emitted for identifiers included in `PROM_REPORT_IDS`.
Some values require telemetry packets to be present—for example, devices must provide
metrics or positional updates before the related gauges appear.
## Accessing the `/metrics` endpoint
Once the application is running, query the exporter directly:
```bash
curl http://localhost:41447/metrics
```
Use any HTTP client capable of plain-text requests. Prometheus scrapers should target
the same URL. The endpoint returns data in the standard exposition format produced by
`prometheus-client`.
## Prometheus scrape configuration
Add a job to your Prometheus server configuration that points to the PotatoMesh
instance. This example polls an instance running locally on the default port every 15
seconds:
```yaml
scrape_configs:
- job_name: potatomesh
scrape_interval: 15s
static_configs:
- targets:
- localhost:41447
```
If your deployment requires authentication or runs behind a reverse proxy, configure
Prometheus to match your network topology (for example by adding basic authentication
credentials, custom headers, or TLS settings).
## Troubleshooting
- **No per-node metrics appear.** Ensure that `PROM_REPORT_IDS` is set and that the
specified nodes exist in the database. Set the value to `*` if you want to export
every node during initial validation.
- **Metrics look stale after a restart.** Confirm that the ingestor is still posting
telemetry. The exporter only reflects data stored in the PotatoMesh database.
- **Scrapes time out.** Verify that the Prometheus server can reach the PotatoMesh
HTTP port and that no reverse proxy is blocking the `/metrics` path.
With the endpoint configured, you can build Grafana dashboards or alerting rules to
keep track of community mesh health in real time.
+31 -11
View File
@@ -70,15 +70,20 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
The web app can be configured with environment variables (defaults shown):
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
* `CHANNEL` - default channel shown in the UI (default: "#LongFast")
* `FREQUENCY` - default frequency shown in the UI (default: "915MHz")
* `MAP_CENTER` - default map center coordinates (default: `38.761944,-27.090833`)
* `MAX_DISTANCE` - hide nodes farther than this distance from the center (default: `42`)
* `CONTACT_LINK` - chat link or Matrix alias for footer and overlay (default: `#potatomesh:dod.ngo`)
* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset)
* `INSTANCE_DOMAIN` - public hostname (optionally with port) used for metadata, federation, and API links (default: auto-detected)
* `FEDERATION` - set to `1` to announce your instance and crawl peers, or `0` to disable federation (default: `1`)
| Variable | Default | Purpose |
| --- | --- | --- |
| `API_TOKEN` | _required_ | Shared secret that authorizes ingestors and API clients making `POST` requests. |
| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) used for metadata, federation, and generated API links. |
| `SITE_NAME` | `"PotatoMesh Demo"` | Title and header displayed in the UI. |
| `CHANNEL` | `"#LongFast"` | Default channel name displayed in the UI. |
| `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. |
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the Meshtastic radio. |
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
@@ -113,6 +118,12 @@ ignored and the ingestor will skip discovery tasks. Private mode still takes
precedence; when `PRIVATE=1`, federation features remain disabled regardless of
the `FEDERATION` value.
When federation is enabled, PotatoMesh automatically refreshes entries from
known peers every eight hours to keep the directory current. Instances that
stop responding are considered stale and are removed from the web frontend after
72 hours, ensuring visitors only see active deployments in the public
directory.
### API
The web app contains an API:
@@ -133,6 +144,12 @@ The web app contains an API:
The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests.
### Observability
PotatoMesh ships with a Prometheus exporter mounted at `/metrics`. Consult
[`PROMETHEUS.md`](./PROMETHEUS.md) for deployment guidance, metric details, and
scrape configuration examples.
## Python Ingestor
The web app is not meant to be run locally connected to a Meshtastic node but rather
@@ -185,11 +202,14 @@ Post your nodes here:
Docker images are published on Github for each release:
```bash
docker pull ghcr.io/l5yth/potato-mesh/web:latest
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release
docker pull ghcr.io/l5yth/potato-mesh/web:v3.0 # pinned historical release
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
```
See the [Docker guide](DOCKER.md) for more details and custome deployment instructions.
Set `POTATOMESH_IMAGE_TAG` in your `.env` (or environment) to deploy a specific
tagged release with Docker Compose. See the [Docker guide](DOCKER.md) for more
details and custom deployment instructions.
## License
+26 -2
View File
@@ -56,10 +56,14 @@ read_with_default() {
update_env() {
local key="$1"
local value="$2"
local escaped_value
# Escape characters that would break the sed replacement delimiter or introduce backreferences
escaped_value=$(printf '%s' "$value" | sed -e 's/[&|]/\\&/g')
if grep -q "^$key=" .env; then
# Update existing value
sed -i.bak "s/^$key=.*/$key=$value/" .env
sed -i.bak "s|^$key=.*|$key=$escaped_value|" .env
else
# Add new value
echo "$key=$value" >> .env
@@ -77,7 +81,10 @@ MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
POTATOMESH_IMAGE_ARCH=$(grep "^POTATOMESH_IMAGE_ARCH=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "linux-amd64")
POTATOMESH_IMAGE_TAG=$(grep "^POTATOMESH_IMAGE_TAG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "latest")
INSTANCE_DOMAIN=$(grep "^INSTANCE_DOMAIN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
DEBUG=$(grep "^DEBUG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
CONNECTION=$(grep "^CONNECTION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "/dev/ttyACM0")
echo "📍 Location Settings"
echo "-------------------"
@@ -95,6 +102,7 @@ echo ""
echo "💬 Optional Settings"
echo "-------------------"
read_with_default "Chat link or Matrix room (optional)" "$CONTACT_LINK" CONTACT_LINK
read_with_default "Debug logging (1=enabled, 0=disabled)" "$DEBUG" DEBUG
echo ""
echo "🤝 Federation Settings"
@@ -115,6 +123,16 @@ echo "🛠 Docker Settings"
echo "------------------"
echo "Specify the Docker image architecture for your host (linux-amd64, linux-arm64, linux-armv7)."
read_with_default "Docker image architecture" "$POTATOMESH_IMAGE_ARCH" POTATOMESH_IMAGE_ARCH
echo "Enter the Docker image tag to deploy (use 'latest' for the newest release or pin a version such as v3.0)."
read_with_default "Docker image tag (latest, vX.Y, etc.)" "$POTATOMESH_IMAGE_TAG" POTATOMESH_IMAGE_TAG
echo ""
echo "🔌 Ingestor Connection"
echo "----------------------"
echo "Define how the mesh ingestor connects to your Meshtastic device."
echo "Use serial devices like /dev/ttyACM0, TCP endpoints such as tcp://host:port,"
echo "or Bluetooth addresses when supported."
read_with_default "Connection target" "$CONNECTION" CONNECTION
echo ""
echo "🌐 Domain Settings"
@@ -164,10 +182,13 @@ update_env "FREQUENCY" "\"$FREQUENCY\""
update_env "MAP_CENTER" "\"$MAP_CENTER\""
update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "DEBUG" "$DEBUG"
update_env "API_TOKEN" "$API_TOKEN"
update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH"
update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG"
update_env "FEDERATION" "$FEDERATION"
update_env "PRIVATE" "$PRIVATE"
update_env "CONNECTION" "$CONNECTION"
if [ -n "$INSTANCE_DOMAIN" ]; then
update_env "INSTANCE_DOMAIN" "$INSTANCE_DOMAIN"
else
@@ -205,8 +226,11 @@ echo " Max Distance: ${MAX_DISTANCE}km"
echo " Channel: $CHANNEL"
echo " Frequency: $FREQUENCY"
echo " Chat: ${CONTACT_LINK:-'Not set'}"
echo " Debug Logging: ${DEBUG}"
echo " Connection: ${CONNECTION}"
echo " API Token: ${API_TOKEN:0:8}..."
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG"
echo " Private Mode: ${PRIVATE}"
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
if [ "${FEDERATION:-1}" = "0" ]; then
+6 -4
View File
@@ -34,6 +34,7 @@ _RECEIVE_TOPICS = (
"meshtastic.receive.NODEINFO_APP",
"meshtastic.receive.NEIGHBORINFO_APP",
"meshtastic.receive.TEXT_MESSAGE_APP",
"meshtastic.receive.REACTION_APP",
"meshtastic.receive.TELEMETRY_APP",
)
@@ -206,7 +207,7 @@ def _connected_state(candidate) -> bool | None:
return None
def main() -> None:
def main(existing_interface=None) -> None:
"""Run the mesh ingestion daemon until interrupted."""
subscribed = _subscribe_receive_topics()
@@ -218,7 +219,7 @@ def main() -> None:
topics=subscribed,
)
iface = None
iface = existing_interface
resolved_target = None
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
@@ -254,8 +255,9 @@ def main() -> None:
return
stop.set()
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
if threading.current_thread() == threading.main_thread():
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
target = config.INSTANCE or "(no POTATOMESH_INSTANCE)"
configured_port = config.CONNECTION
+96 -4
View File
@@ -17,7 +17,10 @@
from __future__ import annotations
import base64
import contextlib
import importlib
import json
import sys
import time
from collections.abc import Mapping
@@ -1051,15 +1054,83 @@ def store_packet_dict(packet: Mapping) -> None:
store_neighborinfo_packet(packet, decoded)
return
text = _first(decoded, "payload.text", "text", default=None)
text = _first(decoded, "payload.text", "text", "data.text", default=None)
encrypted = _first(decoded, "payload.encrypted", "encrypted", default=None)
if encrypted is None:
encrypted = _first(packet, "encrypted", default=None)
if not text and not encrypted:
reply_id_raw = _first(
decoded,
"payload.replyId",
"payload.reply_id",
"data.replyId",
"data.reply_id",
"replyId",
"reply_id",
default=None,
)
reply_id = _coerce_int(reply_id_raw)
emoji_raw = _first(
decoded,
"payload.emoji",
"data.emoji",
"emoji",
default=None,
)
emoji = None
if emoji_raw is not None:
try:
emoji_text = str(emoji_raw)
except Exception:
emoji_text = None
else:
emoji_text = emoji_text.strip()
if emoji_text:
emoji = emoji_text
encrypted_flag = _is_encrypted_flag(encrypted)
if not any([text, encrypted_flag, emoji is not None, reply_id is not None]):
return
if portnum and portnum not in {"1", "TEXT_MESSAGE_APP"}:
return
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
allowed_port_ints = {1}
reaction_port_candidates: set[int] = set()
for module_name in (
"meshtastic.portnums_pb2",
"meshtastic.protobuf.portnums_pb2",
):
module = sys.modules.get(module_name)
if module is None:
with contextlib.suppress(ModuleNotFoundError):
module = importlib.import_module(module_name)
if module is None:
continue
portnum_enum = getattr(module, "PortNum", None)
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
if callable(value_lookup):
with contextlib.suppress(Exception):
candidate = _coerce_int(value_lookup("REACTION_APP"))
if candidate is not None:
reaction_port_candidates.add(candidate)
constant_value = getattr(module, "REACTION_APP", None)
candidate = _coerce_int(constant_value)
if candidate is not None:
reaction_port_candidates.add(candidate)
for candidate in reaction_port_candidates:
allowed_port_ints.add(candidate)
allowed_port_values.add(str(candidate))
is_reaction_packet = portnum == "REACTION_APP" or (
reply_id is not None and emoji is not None
)
if is_reaction_packet and portnum_int is not None:
allowed_port_ints.add(portnum_int)
allowed_port_values.add(str(portnum_int))
if portnum and portnum not in allowed_port_values:
if portnum_int not in allowed_port_ints:
return
channel = _first(decoded, "channel", default=None)
if channel is None:
@@ -1093,6 +1164,25 @@ def store_packet_dict(packet: Mapping) -> None:
encrypted_flag = _is_encrypted_flag(encrypted)
to_id_normalized = str(to_id).strip() if to_id is not None else ""
if (
not is_reaction_packet
and channel == 0
and not encrypted_flag
and to_id_normalized
and to_id_normalized.lower() != "^all"
):
if config.DEBUG:
config._debug_log(
"Skipped direct message on primary channel",
context="handlers.store_packet_dict",
from_id=_canonical_node_id(from_id) or from_id,
to_id=_canonical_node_id(to_id) or to_id,
channel=channel,
)
return
message_payload = {
"id": int(pkt_id),
"rx_time": rx_time,
@@ -1106,6 +1196,8 @@ def store_packet_dict(packet: Mapping) -> None:
"snr": float(snr) if snr is not None else None,
"rssi": int(rssi) if rssi is not None else None,
"hop_limit": int(hop) if hop is not None else None,
"reply_id": reply_id,
"emoji": emoji,
}
channel_name_value = None
+164 -1
View File
@@ -22,6 +22,8 @@ from __future__ import annotations
import base64
import dataclasses
import enum
import importlib
import json
import math
import time
@@ -31,6 +33,18 @@ from google.protobuf.json_format import MessageToDict
from google.protobuf.message import DecodeError
from google.protobuf.message import Message as ProtoMessage
_CLI_ROLE_MODULE_NAMES: tuple[str, ...] = (
"meshtastic.cli.common",
"meshtastic.cli.roles",
"meshtastic.cli.enums",
"meshtastic_cli.common",
"meshtastic_cli.roles",
)
"""Possible module paths that may expose the Meshtastic CLI role enum."""
_CLI_ROLE_LOOKUP: dict[int, str] | None = None
"""Cached mapping of CLI role identifiers to their textual names."""
def _get(obj, key, default=None):
"""Return ``obj[key]`` or ``getattr(obj, key)`` when available.
@@ -49,6 +63,96 @@ def _get(obj, key, default=None):
return getattr(obj, key, default)
def _reset_cli_role_cache() -> None:
"""Clear the cached CLI role lookup mapping.
The helper is primarily used by tests to ensure deterministic behaviour
when substituting stub CLI modules.
Returns:
``None``. The next lookup will trigger a fresh import attempt.
"""
global _CLI_ROLE_LOOKUP
_CLI_ROLE_LOOKUP = None
def _load_cli_role_lookup() -> dict[int, str]:
"""Return a mapping of role identifiers from the Meshtastic CLI.
The Meshtastic CLI exposes extended role enums that may include entries
absent from the protobuf definition shipped with the firmware. This
helper lazily imports the CLI module when present and extracts the
available role names so that numeric values received from the firmware can
be normalised into human-friendly strings.
Returns:
Mapping of integer role identifiers to their canonical string names.
"""
global _CLI_ROLE_LOOKUP
if _CLI_ROLE_LOOKUP is not None:
return _CLI_ROLE_LOOKUP
lookup: dict[int, str] = {}
def _from_candidate(candidate) -> dict[int, str]:
mapping: dict[int, str] = {}
if isinstance(candidate, enum.EnumMeta):
for member in candidate: # pragma: no branch - Enum iteration deterministic
try:
mapping[int(member.value)] = str(member.name)
except Exception: # pragma: no cover - defensive guard
continue
return mapping
members = getattr(candidate, "__members__", None)
if isinstance(members, Mapping):
for name, member in members.items():
value = getattr(member, "value", None)
if isinstance(value, (int, enum.IntEnum)):
try:
mapping[int(value)] = str(name)
except Exception: # pragma: no cover - defensive
continue
if mapping:
return mapping
if isinstance(candidate, Mapping):
for key, value in candidate.items():
try:
key_int = int(key)
except Exception: # pragma: no cover - defensive
continue
mapping[key_int] = str(value)
return mapping
for module_name in _CLI_ROLE_MODULE_NAMES:
try:
module = importlib.import_module(module_name)
except Exception: # pragma: no cover - optional dependency
continue
candidates = []
for attr_name in ("Role", "Roles", "ClientRole", "ClientRoles"):
candidate = getattr(module, attr_name, None)
if candidate is not None:
candidates.append(candidate)
for candidate in candidates:
mapping = _from_candidate(candidate)
if not mapping:
continue
lookup.update(mapping)
if lookup:
break
_CLI_ROLE_LOOKUP = {
key: value.strip().upper()
for key, value in lookup.items()
if isinstance(value, str) and value.strip()
}
return _CLI_ROLE_LOOKUP
def _node_to_dict(n) -> dict:
"""Convert ``n`` into a JSON-serialisable mapping.
@@ -99,6 +203,57 @@ def _node_to_dict(n) -> dict:
return _convert(n)
def _normalize_user_role(value) -> str | None:
"""Return a canonical role string for ``value`` when possible.
Parameters:
value: Raw role descriptor emitted by the Meshtastic firmware or
decoded JSON payloads.
Returns:
Uppercase role string or ``None`` if the value cannot be resolved.
"""
if value is None:
return None
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
return None
return cleaned.upper()
numeric = _coerce_int(value)
if numeric is None:
return None
role_name = None
cli_lookup = _load_cli_role_lookup()
role_name = cli_lookup.get(numeric)
if not role_name:
try: # pragma: no branch - minimal control flow
from meshtastic.protobuf import mesh_pb2
role_name = mesh_pb2.User.Role.Name(numeric)
except Exception: # pragma: no cover - depends on protobuf version
role_name = None
if not role_name:
try:
from meshtastic.protobuf import config_pb2
role_name = config_pb2.Config.DeviceConfig.Role.Name(numeric)
except Exception: # pragma: no cover - depends on protobuf version
role_name = None
if role_name:
return role_name.strip().upper()
return str(numeric)
def upsert_payload(node_id, node) -> dict:
"""Return the payload expected by ``/api/nodes`` upsert requests.
@@ -120,7 +275,7 @@ def _iso(ts: int | float) -> str:
import datetime
return (
datetime.datetime.fromtimestamp(int(ts), datetime.UTC)
datetime.datetime.fromtimestamp(int(ts), datetime.timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
@@ -587,6 +742,11 @@ def _nodeinfo_user_dict(node_info, decoded_user):
if canonical:
user_dict = dict(user_dict)
user_dict["id"] = canonical
role_value = user_dict.get("role")
normalized_role = _normalize_user_role(role_value)
if normalized_role and normalized_role != role_value:
user_dict = dict(user_dict)
user_dict["role"] = normalized_role
return user_dict
@@ -594,6 +754,8 @@ __all__ = [
"_canonical_node_id",
"_coerce_float",
"_coerce_int",
"_load_cli_role_lookup",
"_normalize_user_role",
"_decode_nodeinfo_payload",
"_extract_payload_bytes",
"_first",
@@ -606,6 +768,7 @@ __all__ = [
"_nodeinfo_position_dict",
"_nodeinfo_user_dict",
"_pkt_to_dict",
"_reset_cli_role_cache",
"DecodeError",
"MessageToDict",
"ProtoMessage",
+4 -1
View File
@@ -27,7 +27,9 @@ CREATE TABLE IF NOT EXISTS messages (
hop_limit INTEGER,
lora_freq INTEGER,
modem_preset TEXT,
channel_name TEXT
channel_name TEXT,
reply_id INTEGER,
emoji TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);
@@ -35,3 +37,4 @@ CREATE INDEX IF NOT EXISTS idx_messages_from_id ON messages(from_id);
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel);
CREATE INDEX IF NOT EXISTS idx_messages_portnum ON messages(portnum);
CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id);
@@ -0,0 +1,20 @@
-- Copyright (C) 2025 l5yth
--
-- 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.
--
-- Extend the messages table to capture reply relationships and emoji reactions.
BEGIN;
ALTER TABLE messages ADD COLUMN reply_id INTEGER;
ALTER TABLE messages ADD COLUMN emoji TEXT;
CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id);
COMMIT;
+2 -2
View File
@@ -1,5 +1,5 @@
x-web-base: &web-base
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
environment:
APP_ENV: ${APP_ENV:-production}
RACK_ENV: ${RACK_ENV:-production}
@@ -30,7 +30,7 @@ x-web-base: &web-base
cpus: '0.25'
x-ingestor-base: &ingestor-base
image: ghcr.io/l5yth/potato-mesh-ingestor-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
image: ghcr.io/l5yth/potato-mesh-ingestor-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
environment:
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
+144
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import base64
import enum
import importlib
import re
import sys
@@ -601,11 +602,53 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
assert payload["hop_limit"] == 3
assert payload["snr"] == pytest.approx(1.25)
assert payload["rssi"] == -70
assert payload["reply_id"] is None
assert payload["emoji"] is None
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_posts_reaction_message(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 999,
"rxTime": 1_700_100_000,
"fromId": "!reply",
"toId": "!root",
"decoded": {
"portnum": "REACTION_APP",
"data": {
"reply_id": "123",
"emoji": " 👍 ",
},
},
}
mesh.store_packet_dict(packet)
assert captured, "Expected POST to be triggered for reaction message"
path, payload, priority = captured[0]
assert path == "/api/messages"
assert payload["id"] == 999
assert payload["from_id"] == "!reply"
assert payload["to_id"] == "!root"
assert payload["portnum"] == "REACTION_APP"
assert payload["text"] is None
assert payload["reply_id"] == 123
assert payload["emoji"] == "👍"
assert payload["rx_time"] == 1_700_100_000
assert payload["rx_iso"] == mesh._iso(1_700_100_000)
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_posts_position(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
@@ -1453,6 +1496,65 @@ def test_store_packet_dict_handles_invalid_channel(mesh_module, monkeypatch):
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_skips_direct_message_on_primary_channel(
mesh_module, monkeypatch
):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 111,
"rxTime": 777,
"fromId": "!sender",
"toId": "!recipient",
"channel": 0,
"decoded": {"text": "secret dm", "portnum": "TEXT_MESSAGE_APP"},
}
mesh.store_packet_dict(packet)
assert not captured
def test_store_packet_dict_allows_primary_channel_broadcast(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 915
mesh.config.MODEM_PRESET = "LongSlow"
packet = {
"id": 222,
"rxTime": 888,
"from": "!relay",
"to": "^all",
"channel": 0,
"decoded": {"text": "announcement", "portnum": "TEXT_MESSAGE_APP"},
}
mesh.store_packet_dict(packet)
assert captured
path, payload, priority = captured[0]
assert path == "/api/messages"
assert payload["text"] == "announcement"
assert payload["to_id"] == "^all"
assert payload["channel"] == 0
assert payload["lora_freq"] == 915
assert payload["modem_preset"] == "LongSlow"
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys):
mesh = mesh_module
mesh.channels._reset_channel_cache()
@@ -1504,6 +1606,8 @@ def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys
assert payload["channel"] == 5
assert payload["text"] == "hi"
assert payload["encrypted"] is None
assert payload["reply_id"] is None
assert payload["emoji"] is None
assert priority == mesh._MESSAGE_POST_PRIORITY
log_output = capsys.readouterr().out
@@ -1541,6 +1645,8 @@ def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
assert payload["text"] is None
assert payload["from_id"] == 2988082812
assert payload["to_id"] == "!receiver"
assert payload["reply_id"] is None
assert payload["emoji"] is None
assert "channel_name" not in payload
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
@@ -2191,6 +2297,44 @@ def test_nodeinfo_helpers_cover_fallbacks(mesh_module, monkeypatch):
assert user["id"] == "!11223344"
def test_nodeinfo_user_role_falls_back_to_cli_enum(mesh_module, monkeypatch):
mesh = mesh_module
mesh._reset_cli_role_cache()
cli_module = types.ModuleType("meshtastic.cli")
cli_common = types.ModuleType("meshtastic.cli.common")
class DummyRole(enum.IntEnum):
CLIENT = 0
CLIENT_BASE = 12
cli_common.Role = DummyRole
cli_module.common = cli_common
monkeypatch.setitem(sys.modules, "meshtastic.cli", cli_module)
monkeypatch.setitem(sys.modules, "meshtastic.cli.common", cli_common)
user = mesh._nodeinfo_user_dict(None, {"id": "!11223344", "role": 12})
assert user["role"] == "CLIENT_BASE"
mesh._reset_cli_role_cache()
cli_dict_module = types.ModuleType("meshtastic.cli")
cli_dict_common = types.ModuleType("meshtastic.cli.common")
cli_dict_common.ClientRoles = {12: "client_hidden"}
cli_dict_module.common = cli_dict_common
monkeypatch.setitem(sys.modules, "meshtastic.cli", cli_dict_module)
monkeypatch.setitem(sys.modules, "meshtastic.cli.common", cli_dict_common)
user = mesh._nodeinfo_user_dict(None, {"id": "!11223344", "role": 12})
assert user["role"] == "CLIENT_HIDDEN"
mesh._reset_cli_role_cache()
def test_store_position_packet_defaults(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
+1
View File
@@ -15,6 +15,7 @@
source "https://rubygems.org"
gem "sinatra", "~> 4.0"
gem "erb", "~> 4.0"
gem "sqlite3", "~> 1.7"
gem "rackup", "~> 2.2"
gem "puma", "~> 7.0"
+1 -1
View File
@@ -18,4 +18,4 @@ set -euo pipefail
bundle install
exec ruby app.rb -p 41447 -o 0.0.0.0
exec bundle exec ruby app.rb -p 41447 -o 0.0.0.0
+8
View File
@@ -43,6 +43,7 @@ require_relative "application/errors"
require_relative "application/database"
require_relative "application/networking"
require_relative "application/identity"
require_relative "application/worker_pool"
require_relative "application/federation"
require_relative "application/prometheus"
require_relative "application/queries"
@@ -130,6 +131,7 @@ module PotatoMesh
set :public_folder, File.expand_path("../../public", __dir__)
set :views, File.expand_path("../../views", __dir__)
set :federation_thread, nil
set :federation_worker_pool, nil
set :port, resolve_port
set :bind, DEFAULT_BIND_ADDRESS
@@ -153,6 +155,12 @@ module PotatoMesh
ensure_self_instance_record!
update_all_prometheus_metrics_from_nodes
if federation_enabled?
ensure_federation_worker_pool!
else
shutdown_federation_worker_pool!
end
if federation_announcements_active?
start_initial_federation_announcement!
start_federation_announcer!
@@ -1262,6 +1262,8 @@ module PotatoMesh
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
reply_id = coerce_integer(message["reply_id"] || message["replyId"])
emoji = string_or_nil(message["emoji"])
row = [
msg_id,
@@ -1279,11 +1281,13 @@ module PotatoMesh
lora_freq,
modem_preset,
channel_name,
reply_id,
emoji,
]
with_busy_retry do
existing = db.get_first_row(
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name FROM messages WHERE id = ?",
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name, reply_id, emoji FROM messages WHERE id = ?",
[msg_id],
)
if existing
@@ -1334,6 +1338,19 @@ module PotatoMesh
updates["channel_name"] = channel_name if should_update
end
unless reply_id.nil?
existing_reply = existing.is_a?(Hash) ? existing["reply_id"] : existing[6]
updates["reply_id"] = reply_id if existing_reply != reply_id
end
if emoji
existing_emoji = existing.is_a?(Hash) ? existing["emoji"] : existing[7]
existing_emoji_str = existing_emoji&.to_s
should_update = existing_emoji_str.nil? || existing_emoji_str.strip.empty?
should_update ||= existing_emoji != emoji
updates["emoji"] = emoji if should_update
end
unless updates.empty?
assignments = updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id])
@@ -1343,8 +1360,8 @@ module PotatoMesh
begin
db.execute <<~SQL, row
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name,reply_id,emoji)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
SQL
rescue SQLite3::ConstraintException
fallback_updates = {}
@@ -1354,6 +1371,8 @@ module PotatoMesh
fallback_updates["lora_freq"] = lora_freq unless lora_freq.nil?
fallback_updates["modem_preset"] = modem_preset if modem_preset
fallback_updates["channel_name"] = channel_name if channel_name
fallback_updates["reply_id"] = reply_id unless reply_id.nil?
fallback_updates["emoji"] = emoji if emoji
unless fallback_updates.empty?
assignments = fallback_updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", fallback_updates.values + [msg_id])
@@ -138,6 +138,24 @@ module PotatoMesh
db.execute("ALTER TABLE messages ADD COLUMN channel_name TEXT")
end
unless message_columns.include?("reply_id")
db.execute("ALTER TABLE messages ADD COLUMN reply_id INTEGER")
message_columns << "reply_id"
end
unless message_columns.include?("emoji")
db.execute("ALTER TABLE messages ADD COLUMN emoji TEXT")
message_columns << "emoji"
end
reply_index_exists =
db.get_first_value(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'",
).to_i > 0
unless reply_index_exists
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id)")
end
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten
if tables.empty?
sql_file = File.expand_path("../../../../data/instances.sql", __dir__)
@@ -15,10 +15,25 @@
module PotatoMesh
module App
module Federation
# Resolve the canonical domain for the running instance.
#
# @return [String, nil] sanitized instance domain or nil outside production.
# @raise [RuntimeError] when the domain cannot be determined in production.
def self_instance_domain
sanitized = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
return sanitized if sanitized
unless production_environment?
debug_log(
"INSTANCE_DOMAIN unavailable; skipping self instance domain",
context: "federation.instances",
app_env: string_or_nil(ENV["APP_ENV"]),
rack_env: string_or_nil(ENV["RACK_ENV"]),
source: app_constant(:INSTANCE_DOMAIN_SOURCE),
)
return nil
end
raise "INSTANCE_DOMAIN could not be determined"
end
@@ -111,6 +126,61 @@ module PotatoMesh
db&.close
end
# Retrieve or initialize the worker pool servicing federation jobs.
#
# @return [PotatoMesh::App::WorkerPool, nil] active worker pool or nil when disabled.
def federation_worker_pool
ensure_federation_worker_pool!
end
# Ensure the federation worker pool exists when federation remains enabled.
#
# @return [PotatoMesh::App::WorkerPool, nil] active worker pool if created.
def ensure_federation_worker_pool!
return nil unless federation_enabled?
existing = settings.respond_to?(:federation_worker_pool) ? settings.federation_worker_pool : nil
return existing if existing&.alive?
pool = PotatoMesh::App::WorkerPool.new(
size: PotatoMesh::Config.federation_worker_pool_size,
max_queue: PotatoMesh::Config.federation_worker_queue_capacity,
name: "potato-mesh-fed",
)
at_exit do
begin
pool.shutdown(timeout: PotatoMesh::Config.federation_task_timeout_seconds)
rescue StandardError
# Suppress shutdown errors during interpreter teardown.
end
end
set(:federation_worker_pool, pool) if respond_to?(:set)
pool
end
# Shutdown and clear the federation worker pool if present.
#
# @return [void]
def shutdown_federation_worker_pool!
existing = settings.respond_to?(:federation_worker_pool) ? settings.federation_worker_pool : nil
return unless existing
begin
existing.shutdown(timeout: PotatoMesh::Config.federation_task_timeout_seconds)
rescue StandardError => e
warn_log(
"Failed to shut down federation worker pool",
context: "federation",
error_class: e.class.name,
error_message: e.message,
)
ensure
set(:federation_worker_pool, nil) if respond_to?(:set)
end
end
def federation_target_domains(self_domain)
normalized_self = sanitize_instance_domain(self_domain)&.downcase
ordered = []
@@ -243,9 +313,39 @@ module PotatoMesh
attributes, signature = ensure_self_instance_record!
payload_json = JSON.generate(instance_announcement_payload(attributes, signature))
domains = federation_target_domains(attributes[:domain])
pool = federation_worker_pool
scheduled = []
domains.each do |domain|
if pool
begin
task = pool.schedule do
announce_instance_to_domain(domain, payload_json)
end
scheduled << [domain, task]
next
rescue PotatoMesh::App::WorkerPool::QueueFullError
warn_log(
"Skipped asynchronous federation announcement",
context: "federation.announce",
domain: domain,
reason: "worker queue saturated",
)
rescue PotatoMesh::App::WorkerPool::ShutdownError
warn_log(
"Worker pool unavailable, falling back to synchronous announcement",
context: "federation.announce",
domain: domain,
)
pool = nil
end
end
announce_instance_to_domain(domain, payload_json)
end
wait_for_federation_tasks(scheduled)
unless domains.empty?
debug_log(
"Federation announcement cycle complete",
@@ -255,6 +355,38 @@ module PotatoMesh
end
end
# Wait for scheduled federation tasks to complete while logging failures.
#
# @param scheduled [Array<(String, PotatoMesh::App::WorkerPool::Task)>] pairs of domains and tasks.
# @return [void]
def wait_for_federation_tasks(scheduled)
return if scheduled.empty?
timeout = PotatoMesh::Config.federation_task_timeout_seconds
scheduled.each do |domain, task|
begin
task.wait(timeout: timeout)
rescue PotatoMesh::App::WorkerPool::TaskTimeoutError => e
warn_log(
"Federation announcement task timed out",
context: "federation.announce",
domain: domain,
timeout: timeout,
error_class: e.class.name,
error_message: e.message,
)
rescue StandardError => e
warn_log(
"Federation announcement task failed",
context: "federation.announce",
domain: domain,
error_class: e.class.name,
error_message: e.message,
)
end
end
end
def start_federation_announcer!
# Federation broadcasts must not execute when federation support is disabled.
return nil unless federation_enabled?
@@ -484,6 +616,58 @@ module PotatoMesh
[nil, nil, e.message]
end
# Enqueue a federation crawl for the supplied domain using the worker pool.
#
# @param domain [String] sanitized remote domain to crawl.
# @param per_response_limit [Integer, nil] maximum entries processed per response.
# @param overall_limit [Integer, nil] maximum unique domains visited.
# @return [Boolean] true when the crawl was scheduled successfully.
def enqueue_federation_crawl(domain, per_response_limit:, overall_limit:)
pool = federation_worker_pool
unless pool
debug_log(
"Skipped remote instance crawl",
context: "federation.instances",
domain: domain,
reason: "federation disabled",
)
return false
end
application = is_a?(Class) ? self : self.class
pool.schedule do
db = application.open_database
begin
application.ingest_known_instances_from!(
db,
domain,
per_response_limit: per_response_limit,
overall_limit: overall_limit,
)
ensure
db&.close
end
end
true
rescue PotatoMesh::App::WorkerPool::QueueFullError
warn_log(
"Skipped remote instance crawl",
context: "federation.instances",
domain: domain,
reason: "worker queue saturated",
)
false
rescue PotatoMesh::App::WorkerPool::ShutdownError
warn_log(
"Skipped remote instance crawl",
context: "federation.instances",
domain: domain,
reason: "worker pool shut down",
)
false
end
# Recursively ingest federation records exposed by the supplied domain.
#
# @param db [SQLite3::Database] open database connection used for writes.
@@ -334,6 +334,16 @@ module PotatoMesh
ENV["RACK_ENV"] == "test"
end
# Determine whether the application is running in a production environment.
#
# @return [Boolean] true when APP_ENV or RACK_ENV resolves to "production".
def production_environment?
app_env = string_or_nil(ENV["APP_ENV"])&.downcase
rack_env = string_or_nil(ENV["RACK_ENV"])&.downcase
app_env == "production" || rack_env == "production"
end
# Determine whether federation features should be active.
#
# @return [Boolean] true when federation configuration allows it.
+7 -2
View File
@@ -216,7 +216,9 @@ module PotatoMesh
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = ["COALESCE(TRIM(m.encrypted), '') = ''"]
where_clauses = [
"(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '' OR m.reply_id IS NOT NULL OR COALESCE(TRIM(m.emoji), '') != '')",
]
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
where_clauses << "m.rx_time >= ?"
@@ -232,7 +234,8 @@ module PotatoMesh
sql = <<~SQL
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
m.lora_freq, m.modem_preset, m.channel_name, m.snr
m.lora_freq, m.modem_preset, m.channel_name, m.snr,
m.reply_id, m.emoji
FROM messages m
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n"
@@ -244,6 +247,8 @@ module PotatoMesh
rows = db.execute(sql, params)
rows.each do |r|
r.delete_if { |key, _| key.is_a?(Integer) }
r["reply_id"] = coerce_integer(r["reply_id"]) if r.key?("reply_id")
r["emoji"] = string_or_nil(r["emoji"]) if r.key?("emoji")
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?)
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
debug_log(
@@ -240,8 +240,7 @@ module PotatoMesh
db = open_database
upsert_instance_record(db, attributes, signature)
ingest_known_instances_from!(
db,
enqueued = enqueue_federation_crawl(
attributes[:domain],
per_response_limit: PotatoMesh::Config.federation_max_instances_per_response,
overall_limit: PotatoMesh::Config.federation_max_domains_per_crawl,
@@ -251,6 +250,7 @@ module PotatoMesh
context: "ingest.register",
domain: attributes[:domain],
instance_id: attributes[:id],
crawl_enqueued: enqueued,
)
status 201
{ status: "registered" }.to_json
@@ -0,0 +1,212 @@
# 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.
# frozen_string_literal: true
module PotatoMesh
module App
# WorkerPool executes submitted blocks using a bounded set of Ruby threads.
#
# The pool enforces an upper bound on queued tasks, surfaces errors raised
# by jobs, and supports graceful shutdown during application teardown.
class WorkerPool
# Raised when the worker pool queue has reached its configured capacity.
class QueueFullError < StandardError; end
# Raised when a task fails to complete before the requested timeout.
class TaskTimeoutError < StandardError; end
# Raised when scheduling occurs after the pool has been shut down.
class ShutdownError < StandardError; end
# Internal structure responsible for coordinating task completion.
class Task
# @return [Object, nil] value produced by the task block when available.
attr_reader :value
# @return [StandardError, nil] error raised by the task block when set.
attr_reader :error
def initialize
@mutex = Mutex.new
@condition = ConditionVariable.new
@complete = false
@value = nil
@error = nil
end
# Mark the task as completed successfully.
#
# @param result [Object] value produced by the job.
# @return [void]
def fulfill(result)
@mutex.synchronize do
return if @complete
@complete = true
@value = result
@condition.broadcast
end
end
# Mark the task as failed with the provided error.
#
# @param failure [StandardError] exception raised while executing the job.
# @return [void]
def reject(failure)
@mutex.synchronize do
return if @complete
@complete = true
@error = failure
@condition.broadcast
end
end
# Wait for the task to complete, raising any stored failure.
#
# @param timeout [Numeric, nil] optional timeout in seconds.
# @return [Object] the value produced by the job when successful.
# @raise [TaskTimeoutError] when the timeout elapses prior to completion.
# @raise [StandardError] when the job raised an exception.
def wait(timeout: nil)
deadline = timeout && monotonic_now + timeout
@mutex.synchronize do
until @complete
if deadline
remaining = deadline - monotonic_now
raise TaskTimeoutError, "task deadline exceeded" if remaining <= 0
@condition.wait(@mutex, remaining)
else
@condition.wait(@mutex)
end
end
raise @error if @error
@value
end
end
# Check whether the task has finished executing.
#
# @return [Boolean] true when the task is complete.
def complete?
@mutex.synchronize { @complete }
end
private
def monotonic_now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
STOP_SIGNAL = Object.new
# @return [Array<Thread>] threads created to service the pool.
attr_reader :threads
# Initialize a worker pool using the supplied configuration.
#
# @param size [Integer] number of worker threads to spawn.
# @param max_queue [Integer, nil] optional upper bound on queued jobs.
# @param name [String] prefix assigned to worker thread names.
def initialize(size:, max_queue: nil, name: "worker-pool")
raise ArgumentError, "size must be positive" unless size.is_a?(Integer) && size.positive?
@name = name
@queue = max_queue ? SizedQueue.new(max_queue) : Queue.new
@threads = []
@stopped = false
@mutex = Mutex.new
spawn_workers(size)
end
# Determine whether the worker pool is still accepting work.
#
# @return [Boolean] true when the pool remains active.
def alive?
@mutex.synchronize { !@stopped }
end
# Submit a block of work for asynchronous execution.
#
# @yieldreturn [Object] result produced by the job block.
# @return [Task] task tracking the asynchronous execution.
# @raise [QueueFullError] when the queue cannot accept additional work.
# @raise [ShutdownError] when the pool is no longer active.
def schedule(&block)
raise ArgumentError, "block required" unless block
task = Task.new
@mutex.synchronize do
raise ShutdownError, "worker pool has been shut down" if @stopped
begin
@queue.push([task, block], true)
rescue ThreadError => e
raise QueueFullError, e.message
end
end
task
end
# Stop accepting work and wait for the worker threads to finish.
#
# @param timeout [Numeric, nil] seconds to wait for each worker to exit.
# @return [void]
def shutdown(timeout: nil)
threads = nil
@mutex.synchronize do
return if @stopped
@stopped = true
threads = @threads.dup
end
threads.each { @queue << STOP_SIGNAL }
threads.each { |thread| thread.join(timeout) }
end
private
def spawn_workers(size)
size.times do |index|
worker = Thread.new do
Thread.current.name = "#{@name}-#{index}" if Thread.current.respond_to?(:name=)
Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
loop do
task, block = @queue.pop
break if task.equal?(STOP_SIGNAL)
begin
result = block.call
task.fulfill(result)
rescue StandardError => e
task.reject(e)
end
end
end
@threads << worker
end
end
end
end
end
+56 -2
View File
@@ -36,8 +36,25 @@ module PotatoMesh
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 60
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
DEFAULT_FEDERATION_WORKER_POOL_SIZE = 4
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY = 128
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS = 120
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
# Retrieve the configured API token used for authenticated requests.
#
# @return [String, nil] API token when provided, otherwise nil.
def api_token
fetch_string("API_TOKEN", nil)
end
# Retrieve an explicit instance domain override when present.
#
# @return [String, nil] hostname or host:port pair supplied via ENV.
def instance_domain
fetch_string("INSTANCE_DOMAIN", nil)
end
# Determine whether private mode should be activated.
#
# @return [Boolean] true when PRIVATE=1 in the environment.
@@ -156,7 +173,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.3"
"v0.5.4"
end
# Default refresh interval for frontend polling routines.
@@ -201,7 +218,7 @@ module PotatoMesh
#
# @return [String] comma separated list of report IDs.
def prom_report_ids
""
fetch_string("PROM_REPORT_IDS", "")
end
# Transform Prometheus report identifiers into a cleaned array.
@@ -342,6 +359,36 @@ module PotatoMesh
)
end
# Determine the worker pool size used for federation tasks.
#
# @return [Integer] number of worker threads dedicated to federation jobs.
def federation_worker_pool_size
fetch_positive_integer(
"FEDERATION_WORKERS",
DEFAULT_FEDERATION_WORKER_POOL_SIZE,
)
end
# Determine the queue capacity for pending federation jobs.
#
# @return [Integer] maximum number of queued tasks before rejecting work.
def federation_worker_queue_capacity
fetch_positive_integer(
"FEDERATION_WORK_QUEUE",
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY,
)
end
# Determine the timeout applied when awaiting federation worker tasks.
#
# @return [Integer] seconds to wait for asynchronous jobs to complete.
def federation_task_timeout_seconds
fetch_positive_integer(
"FEDERATION_TASK_TIMEOUT",
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS,
)
end
# Maximum acceptable age for remote node data.
#
# @return [Integer] seconds before remote nodes are considered stale.
@@ -447,6 +494,13 @@ module PotatoMesh
fetch_string("CONTACT_LINK", DEFAULT_CONTACT_LINK)
end
# Retrieve the configured connection target for the ingestor service.
#
# @return [String] serial device, TCP endpoint, or Bluetooth target.
def connection_target
fetch_string("CONNECTION", "/dev/ttyACM0")
end
# Determine the best URL to represent the configured contact link.
#
# @return [String, nil] absolute URL when derivable, otherwise nil.
@@ -19,6 +19,7 @@ import {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatChatPresetTag,
formatNodeAnnouncementPrefix,
__test__
} from '../chat-format.js';
@@ -28,7 +29,13 @@ const {
normalizeString,
normalizeFrequency,
normalizeFrequencySlot,
FREQUENCY_PLACEHOLDER
FREQUENCY_PLACEHOLDER,
resolveModemPresetCandidate,
normalizePresetString,
abbreviatePreset,
derivePresetInitials,
normalizePresetSlot,
PRESET_PLACEHOLDER
} = __test__;
test('extractChatMessageMetadata prefers explicit region_frequency and channel_name', () => {
@@ -39,21 +46,32 @@ test('extractChatMessageMetadata prefers explicit region_frequency and channel_n
channelName: 'Ignored'
};
const result = extractChatMessageMetadata(payload);
assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel' });
assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel', presetCode: null });
});
test('extractChatMessageMetadata falls back to LoRa metadata', () => {
const payload = {
lora_freq: 915,
channelName: 'SpecChannel'
channelName: 'SpecChannel',
modem_preset: 'MediumFast'
};
const result = extractChatMessageMetadata(payload);
assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel' });
assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel', presetCode: 'MF' });
});
test('extractChatMessageMetadata returns null metadata for invalid input', () => {
assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null });
assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null });
assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null, presetCode: null });
assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null, presetCode: null });
});
test('extractChatMessageMetadata inspects nested node payloads for modem presets', () => {
const payload = {
node: {
modem_preset: 'ShortTurbo'
}
};
const result = extractChatMessageMetadata(payload);
assert.equal(result.presetCode, 'ST');
});
test('firstNonNull returns the first non-null candidate', () => {
@@ -107,6 +125,11 @@ test('formatChatChannelTag wraps channel names after the short name slot', () =>
);
});
test('formatChatPresetTag renders preset hints with placeholders', () => {
assert.equal(formatChatPresetTag({ presetCode: 'MF' }), '[MF]');
assert.equal(formatChatPresetTag({ presetCode: null }), `[${PRESET_PLACEHOLDER}]`);
});
test('formatNodeAnnouncementPrefix includes optional frequency bracket', () => {
assert.equal(
formatNodeAnnouncementPrefix({ timestamp: '12:34:56', frequency: '868' }),
@@ -124,3 +147,32 @@ test('normalizeFrequencySlot returns placeholder when frequency is missing', ()
assert.equal(normalizeFrequencySlot(undefined), FREQUENCY_PLACEHOLDER);
assert.equal(normalizeFrequencySlot('915'), '915');
});
test('resolveModemPresetCandidate walks nested payloads', () => {
const nested = { node: { modemPreset: 'LongFast' } };
assert.equal(resolveModemPresetCandidate(nested), 'LongFast');
});
test('normalizePresetString trims strings and ignores empties', () => {
assert.equal(normalizePresetString(' MediumSlow '), 'MediumSlow');
assert.equal(normalizePresetString(' '), null);
assert.equal(normalizePresetString(null), null);
});
test('abbreviatePreset maps known presets to codes', () => {
assert.equal(abbreviatePreset('VeryLongSlow'), 'VL');
assert.equal(abbreviatePreset('customPreset'), 'CP');
assert.equal(abbreviatePreset('X'), 'X?');
});
test('derivePresetInitials falls back to segmented tokens', () => {
assert.equal(derivePresetInitials('Long Moderate'), 'LM');
assert.equal(derivePresetInitials('ShortTurbo'), 'ST');
assert.equal(derivePresetInitials('Z'), 'Z?');
});
test('normalizePresetSlot enforces placeholders and uppercase output', () => {
assert.equal(normalizePresetSlot('mf'), 'MF');
assert.equal(normalizePresetSlot(''), PRESET_PLACEHOLDER);
assert.equal(normalizePresetSlot(null), PRESET_PLACEHOLDER);
});
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { formatPositionHighlights, formatTelemetryHighlights } from '../chat-log-highlights.js';
test('formatTelemetryHighlights includes formatted numeric metrics', () => {
const highlights = formatTelemetryHighlights({
temperature: 21.44,
relative_humidity: 54.27,
});
assert.deepEqual(highlights, [
{ label: 'Temperature', value: '21.4°C' },
{ label: 'Humidity', value: '54.3%' },
]);
});
test('formatTelemetryHighlights prefers nested telemetry when top-level values are stale', () => {
const highlights = formatTelemetryHighlights({
channel_utilization: 0,
device_metrics: { channelUtilization: 0.561 },
});
assert.deepEqual(highlights, [
{ label: 'Channel Util', value: '0.561%' },
]);
});
test('formatPositionHighlights renders coordinate and movement data', () => {
const highlights = formatPositionHighlights({
latitude: 52.1234567,
longitude: 13.7654321,
altitude: 150.5,
accuracy: 3.2,
speed: 1.234,
heading: 181.6,
satellites: 7,
});
assert.deepEqual(highlights, [
{ label: 'Lat', value: '52.12346' },
{ label: 'Lon', value: '13.76543' },
{ label: 'Alt', value: '150.5m' },
{ label: 'Accuracy', value: '3.2m' },
{ label: 'Speed', value: '1.2 m/s' },
{ label: 'Heading', value: '182°' },
{ label: 'Sats', value: '7' },
]);
});
test('formatPositionHighlights normalises integer microdegree fields', () => {
const highlights = formatPositionHighlights({
position: {
latitude_i: 52_123_456,
longitude_i: 13_765_432,
},
});
assert.deepEqual(highlights.slice(0, 2), [
{ label: 'Lat', value: '52.12346' },
{ label: 'Lon', value: '13.76543' },
]);
});
test('formatters return empty arrays when payloads are missing', () => {
assert.deepEqual(formatTelemetryHighlights(null), []);
assert.deepEqual(formatPositionHighlights(undefined), []);
assert.deepEqual(formatPositionHighlights({}), []);
});
test('formatPositionHighlights omits zero-valued movement metrics while keeping coordinates', () => {
const highlights = formatPositionHighlights({
latitude: 0,
longitude: 0,
altitude: 0,
speed: '0',
accuracy: 0,
});
assert.deepEqual(highlights, [
{ label: 'Lat', value: '0.00000' },
{ label: 'Lon', value: '0.00000' },
]);
});
@@ -0,0 +1,186 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 {
CHAT_LOG_ENTRY_TYPES,
buildChatTabModel,
MAX_CHANNEL_INDEX,
normaliseChannelIndex,
normaliseChannelName,
resolveTimestampSeconds
} from '../chat-log-tabs.js';
const NOW = 1_000_000;
const WINDOW = 60 * 60; // one hour
function fixtureNodes() {
return [
{ id: 'recent-node', first_heard: NOW - 120 },
{ id: 'stale-node', first_heard: NOW - WINDOW - 1 },
{ id: 'iso-node', firstHeard: null, first_heard_iso: new Date((NOW - 30) * 1000).toISOString() }
];
}
function fixtureMessages() {
return [
{ id: 'recent-default', rx_time: NOW - 5, channel: 0, channel_name: ' MediumFast ' },
{ id: 'primary-preset', rx_time: NOW - 8, channel: 0, modem_preset: ' ShortFast ' },
{ id: 'env-default', rx_time: NOW - 12, channel: 0 },
{ id: 'recent-alt', rx_time: NOW - 10, channel_index: '1', channel_name: ' BerlinMesh ' },
{ id: 'stale', rx_time: NOW - WINDOW - 5, channel: 2 },
{ id: 'encrypted', rx_time: NOW - 20, channel: 3, encrypted: true },
{ id: 'no-index', rx_time: NOW - 15, channel_name: 'Fallback' },
{ id: 'too-high', rx_time: NOW - 25, channel: MAX_CHANNEL_INDEX + 5, channel_name: 'Ignored' },
{ id: 'iso-ts', rxTime: null, rx_iso: new Date((NOW - 40) * 1000).toISOString(), channel: 1 }
];
}
function buildModel(overrides = {}) {
return buildChatTabModel({
nodes: fixtureNodes(),
messages: fixtureMessages(),
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: '#EnvDefault',
...overrides
});
}
test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const model = buildModel();
assert.equal(model.logEntries.length, 3);
assert.deepEqual(model.logEntries.map(entry => entry.type), [
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED
]);
assert.deepEqual(
model.logEntries.map(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED ? entry.message.id : entry.node.id),
['recent-node', 'iso-node', 'encrypted']
);
assert.equal(model.channels.length, 5);
assert.deepEqual(model.channels.map(channel => channel.label), [
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast',
'BerlinMesh'
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
const envChannel = channelByLabel.EnvDefault;
assert.equal(envChannel.index, 0);
assert.equal(envChannel.id, 'channel-0-envdefault');
assert.deepEqual(envChannel.entries.map(entry => entry.message.id), ['env-default']);
const fallbackChannel = channelByLabel.Fallback;
assert.equal(fallbackChannel.index, 0);
assert.equal(fallbackChannel.id, 'channel-0-fallback');
assert.deepEqual(fallbackChannel.entries.map(entry => entry.message.id), ['no-index']);
const namedPrimaryChannel = channelByLabel.MediumFast;
assert.equal(namedPrimaryChannel.index, 0);
assert.equal(namedPrimaryChannel.id, 'channel-0-mediumfast');
assert.deepEqual(namedPrimaryChannel.entries.map(entry => entry.message.id), ['recent-default']);
const presetChannel = channelByLabel.ShortFast;
assert.equal(presetChannel.index, 0);
assert.equal(presetChannel.id, 'channel-0-shortfast');
assert.deepEqual(presetChannel.entries.map(entry => entry.message.id), ['primary-preset']);
const secondaryChannel = channelByLabel.BerlinMesh;
assert.equal(secondaryChannel.index, 1);
assert.equal(secondaryChannel.id, 'channel-1');
assert.equal(secondaryChannel.entries.length, 2);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
});
test('buildChatTabModel always includes channel zero bucket', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[0].entries.length, 0);
});
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
const model = buildChatTabModel({
nodes: [],
messages: [{ id: 'plain', rx_time: NOW - 5, channel: 0 }],
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: ''
});
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].label, '0');
assert.equal(model.channels[0].id, 'channel-0');
});
test('normaliseChannelIndex handles numeric and textual input', () => {
assert.equal(normaliseChannelIndex(2.9), 2);
assert.equal(normaliseChannelIndex(' 7 '), 7);
assert.equal(normaliseChannelIndex('bad'), null);
assert.equal(normaliseChannelIndex(null), null);
});
test('normaliseChannelName trims strings and allows numeric values', () => {
assert.equal(normaliseChannelName(' Berlin '), 'Berlin');
assert.equal(normaliseChannelName(5), '5');
assert.equal(normaliseChannelName(''), null);
assert.equal(normaliseChannelName(undefined), null);
});
test('resolveTimestampSeconds prefers numeric but falls back to ISO parsing', () => {
assert.equal(resolveTimestampSeconds(1234, null), 1234);
const iso = '1970-01-01T00:10:00Z';
assert.equal(resolveTimestampSeconds('not-numeric', iso), 600);
assert.equal(resolveTimestampSeconds('bad', 'invalid'), null);
});
test('buildChatTabModel includes telemetry, position, and neighbor events', () => {
const nodeId = '!node';
const neighborId = '!peer';
const model = buildChatTabModel({
nodes: [{
node_id: nodeId,
first_heard: NOW - 50,
last_heard: NOW - 40,
short_name: 'NODE',
long_name: 'Node Example'
}],
telemetry: [{ node_id: nodeId, rx_time: NOW - 30 }],
positions: [{ node_id: nodeId, rx_time: NOW - 20 }],
neighbors: [{ node_id: nodeId, neighbor_id: neighborId, rx_time: NOW - 10 }],
messages: [],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.deepEqual(model.logEntries.map(entry => entry.type), [
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.NODE_INFO,
CHAT_LOG_ENTRY_TYPES.TELEMETRY,
CHAT_LOG_ENTRY_TYPES.POSITION,
CHAT_LOG_ENTRY_TYPES.NEIGHBOR
]);
assert.equal(model.logEntries[0].nodeId, nodeId);
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
});
@@ -0,0 +1,109 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { CHAT_LOG_ENTRY_TYPES } from '../chat-log-tabs.js';
import {
chatLogEntryMatchesQuery,
chatMessageMatchesQuery,
filterChatModel,
normaliseChatFilterQuery
} from '../chat-search.js';
test('normaliseChatFilterQuery lower-cases and trims user input', () => {
assert.equal(normaliseChatFilterQuery(' MIXED Case '), 'mixed case');
assert.equal(normaliseChatFilterQuery(null), '');
});
test('chatMessageMatchesQuery inspects text and node metadata', () => {
const message = { text: 'Hello Mesh', node: { short_name: 'ALFA', long_name: 'Alpha Node' } };
const helloQuery = normaliseChatFilterQuery('mesh');
assert.equal(chatMessageMatchesQuery(message, helloQuery), true);
const aliasQuery = normaliseChatFilterQuery('alfa');
assert.equal(chatMessageMatchesQuery(message, aliasQuery), true);
const missQuery = normaliseChatFilterQuery('bravo');
assert.equal(chatMessageMatchesQuery(message, missQuery), false);
});
test('chatLogEntryMatchesQuery recognises position highlight values', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.POSITION,
ts: 1,
position: { latitude: 51.5, longitude: 0 },
node: { node_id: '!alpha', short_name: 'Alpha' }
};
const query = normaliseChatFilterQuery('51.50000');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
const missQuery = normaliseChatFilterQuery('bravo');
assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false);
});
test('chatLogEntryMatchesQuery uses enriched node context for lookups', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.TELEMETRY,
nodeId: '!alpha',
telemetry: { voltage: 12.1 },
node: { short_name: 'ALFA', long_name: 'Alpha Node' }
};
const query = normaliseChatFilterQuery('alpha node');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('chatLogEntryMatchesQuery inspects neighbor node context', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR,
neighborId: '!bravo',
neighborNode: { short_name: 'BRAV', long_name: 'Bravo Station' }
};
const query = normaliseChatFilterQuery('bravo station');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('filterChatModel filters both log entries and channel messages', () => {
const model = {
logEntries: [
{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } },
{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!bravo', node: { short_name: 'Bravo' } }
],
channels: [
{
index: 0,
label: '0',
entries: [
{ ts: 1, message: { text: 'Ping Alpha', node: { short_name: 'Alpha' } } },
{ ts: 2, message: { text: 'Ack Bravo', node: { short_name: 'Bravo' } } }
]
}
]
};
const result = filterChatModel(model, 'bravo');
assert.equal(result.logEntries.length, 1);
assert.equal(result.logEntries[0].nodeId, '!bravo');
assert.equal(result.channels.length, 1);
assert.deepEqual(result.channels[0].entries.map(entry => entry.message.text), ['Ack Bravo']);
});
test('filterChatModel returns original references when query is empty', () => {
const model = {
logEntries: [{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } }],
channels: [{ index: 0, label: '0', entries: [] }]
};
const result = filterChatModel(model, ' ');
assert.strictEqual(result.logEntries, model.logEntries);
assert.strictEqual(result.channels, model.channels);
});
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { renderChatTabs } from '../chat-tabs.js';
class MockClassList {
constructor() {
this._values = new Set();
}
add(...names) {
names.forEach(name => {
if (name) this._values.add(name);
});
}
remove(...names) {
names.forEach(name => {
if (name) this._values.delete(name);
});
}
contains(name) {
return this._values.has(name);
}
}
class MockFragment {
constructor() {
this.children = [];
this.isFragment = true;
}
appendChild(node) {
this.children.push(node);
return node;
}
}
class MockElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.attributes = new Map();
this.dataset = {};
this.classList = new MockClassList();
this.listeners = new Map();
this.hidden = false;
this.scrollTop = 0;
this.scrollHeight = 200;
}
appendChild(node) {
this.children.push(node);
return node;
}
replaceChildren(...nodes) {
this.children = [];
for (const node of nodes) {
if (!node) continue;
if (node.isFragment && Array.isArray(node.children)) {
this.children.push(...node.children);
} else {
this.children.push(node);
}
}
}
setAttribute(name, value) {
const strValue = String(value);
this.attributes.set(name, strValue);
if (name === 'id') {
this.id = strValue;
}
if (name.startsWith('data-')) {
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
this.dataset[key] = strValue;
}
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
addEventListener(event, handler) {
this.listeners.set(event, handler);
}
dispatch(event) {
const handler = this.listeners.get(event);
if (handler) {
handler({});
}
}
}
function createMockDocument() {
return {
createElement(tag) {
return new MockElement(tag);
},
createDocumentFragment() {
return new MockFragment();
}
};
}
test('renderChatTabs creates tab markup and selects default active tab', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') },
{ id: 'channel-1', label: 'Alt', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'channel-0');
assert.equal(container.dataset.activeTab, 'channel-0');
assert.equal(container.children.length, 2);
const [tabList, panelWrapper] = container.children;
assert.equal(tabList.children.length, 3);
assert.equal(panelWrapper.children.length, 3);
assert.equal(panelWrapper.children[1].hidden, false);
assert.equal(panelWrapper.children[1].scrollTop, panelWrapper.children[1].scrollHeight);
assert.equal(panelWrapper.children[0].hidden, true);
tabList.children[0].dispatch('click');
assert.equal(container.dataset.activeTab, 'log');
assert.equal(panelWrapper.children[0].hidden, false);
assert.equal(panelWrapper.children[1].hidden, true);
});
test('renderChatTabs reuses previous active tab when still available', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.dataset.activeTab = 'log';
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
previousActiveTabId: 'log',
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'log');
const [tabList, panels] = container.children;
assert.equal(tabList.children[0].getAttribute('aria-selected'), 'true');
assert.equal(panels.children[0].hidden, false);
});
test('renderChatTabs clears container when no tabs exist', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.replaceChildren(new MockElement('span'));
const active = renderChatTabs({ document, container, tabs: [] });
assert.equal(active, null);
assert.equal(container.children.length, 0);
assert.equal(container.dataset.activeTab, '');
});
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 {
buildMessageBody,
buildMessageIndex,
normaliseMessageId,
resolveReplyPrefix
} from '../message-replies.js';
test('normaliseMessageId coerces numeric identifiers', () => {
assert.equal(normaliseMessageId(42), '42');
assert.equal(normaliseMessageId(' 0042 '), '42');
assert.equal(normaliseMessageId('alpha'), 'alpha');
assert.equal(normaliseMessageId(null), null);
});
test('buildMessageIndex normalises identifiers and ignores duplicates', () => {
const messages = [
{ id: '001', text: 'first' },
{ packet_id: 1, text: 'second' },
{ id: '2', text: 'third' }
];
const index = buildMessageIndex(messages);
assert.equal(index.size, 2);
assert.equal(index.get('1'), messages[0]);
assert.equal(index.get('2'), messages[2]);
});
test('resolveReplyPrefix renders reply badge and buildMessageBody joins emoji', () => {
const parent = {
id: 99,
node: { short_name: 'BEEF', long_name: 'Parent Node', role: 'CLIENT' },
text: 'parent message'
};
const reaction = { id: 100, reply_id: 99, emoji: '🔥' };
const index = buildMessageIndex([parent, reaction]);
const prefix = resolveReplyPrefix({
message: reaction,
messagesById: index,
nodesById: new Map(),
renderShortHtml: (short, role, longName) => `SHORT(${short}|${role}|${longName})`,
escapeHtml: value => `ESC(${value})`
});
assert.equal(
prefix,
'<span class="chat-entry-reply">[ESC(in reply to) SHORT(BEEF|CLIENT|Parent Node)]</span>'
);
const body = buildMessageBody({
message: { text: 'Hello', emoji: ' 🔥 ' },
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(body, 'ESC(Hello) EMOJI(🔥)');
});
@@ -81,6 +81,48 @@ test('collectTelemetryMetrics extracts values from nested payloads', () => {
assert.equal(metrics.soilTemperature, 18.9);
});
test('collectTelemetryMetrics prefers latest nested telemetry values over stale top-level metrics', () => {
const payload = {
channel_utilization: 0,
device_metrics: {
channel_utilization: 0.561,
air_util_tx: 0.0091,
},
telemetry: {
channel: 0.563,
},
raw: {
device_metrics: {
channelUtilization: 0.562,
},
},
};
const metrics = collectTelemetryMetrics(payload);
assert.equal(metrics.channel, 0.563);
assert.equal(metrics.airUtil, 0.0091);
});
test('collectTelemetryMetrics prefers utilisation metrics over channel indices', () => {
const metrics = collectTelemetryMetrics({
channel: 0,
channel_utilization: 0.013,
});
assert.equal(metrics.channel, 0.013);
});
test('collectTelemetryMetrics prefers air util tx metrics over derived ratios', () => {
const metrics = collectTelemetryMetrics({
airUtil: 0,
device_metrics: {
air_util_tx: 0.0293
}
});
assert.equal(metrics.airUtil, 0.0293);
});
test('collectTelemetryMetrics ignores non-numeric values', () => {
const metrics = collectTelemetryMetrics({
battery: '',
+166 -4
View File
@@ -18,12 +18,12 @@
* Extract channel metadata from a message payload for chat display.
*
* @param {Object} message Raw message payload from the API.
* @returns {{ frequency: string|null, channelName: string|null }}
* @returns {{ frequency: string|null, channelName: string|null, presetCode: string|null }}
* Normalized metadata values.
*/
export function extractChatMessageMetadata(message) {
if (!message || typeof message !== 'object') {
return { frequency: null, channelName: null };
return { frequency: null, channelName: null, presetCode: null };
}
const frequency = normalizeFrequency(
@@ -40,7 +40,10 @@ export function extractChatMessageMetadata(message) {
firstNonNull(message.channel_name, message.channelName)
);
return { frequency, channelName };
const modemPreset = normalizePresetString(resolveModemPresetCandidate(message));
const presetCode = modemPreset ? abbreviatePreset(modemPreset) : null;
return { frequency, channelName, presetCode };
}
/**
@@ -92,6 +95,17 @@ export function formatNodeAnnouncementPrefix({ timestamp, frequency }) {
return `[${ts}][${freq}]`;
}
/**
* Render the preset hint bracket inserted between the prefix and short name.
*
* @param {{ presetCode: string|null }} params Normalized preset abbreviation.
* @returns {string} HTML-ready bracket slot.
*/
export function formatChatPresetTag({ presetCode }) {
const slot = normalizePresetSlot(presetCode);
return `[${slot}]`;
}
/**
* Produce a consistently formatted frequency slot for chat prefixes.
*
@@ -119,6 +133,28 @@ function normalizeFrequencySlot(value) {
*/
const FREQUENCY_PLACEHOLDER = '&nbsp;&nbsp;&nbsp;';
/**
* HTML placeholder for missing preset abbreviations.
* @type {string}
*/
const PRESET_PLACEHOLDER = '&nbsp;&nbsp;';
/**
* Canonical preset abbreviations keyed by a normalized preset token.
* @type {Record<string, string>}
*/
const PRESET_ABBREVIATIONS = {
verylongslow: 'VL',
longslow: 'LS',
longmoderate: 'LM',
longfast: 'LF',
mediumslow: 'MS',
mediumfast: 'MF',
shortslow: 'SS',
shortfast: 'SF',
shortturbo: 'ST',
};
/**
* Return the first value in ``candidates`` that is not ``null`` or ``undefined``.
*
@@ -182,6 +218,126 @@ function normalizeFrequency(value) {
return null;
}
/**
* Resolve a modem preset candidate from the provided source object.
*
* @param {*} source Source payload potentially containing modem metadata.
* @param {Set<object>} [visited] Visited references to avoid recursion loops.
* @returns {*|null} Raw modem preset candidate.
*/
function resolveModemPresetCandidate(source, visited = new Set()) {
if (!source || typeof source !== 'object') {
return null;
}
if (visited.has(source)) {
return null;
}
visited.add(source);
const candidate = firstNonNull(
source.modemPreset,
source.modem_preset,
source.modempreset,
source.ModemPreset
);
if (candidate != null) {
return candidate;
}
if (source.node && typeof source.node === 'object') {
const nested = resolveModemPresetCandidate(source.node, visited);
if (nested != null) {
return nested;
}
}
return null;
}
/**
* Convert arbitrary preset input to a trimmed string.
*
* @param {*} value Raw preset candidate.
* @returns {string|null} Clean preset string.
*/
function normalizePresetString(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number') {
return String(value);
}
return null;
}
/**
* Produce a two-character abbreviation for a modem preset.
*
* @param {string} preset Normalized preset string.
* @returns {string|null} Uppercase abbreviation or ``null``.
*/
function abbreviatePreset(preset) {
if (!preset) {
return null;
}
const token = preset.replace(/[^A-Za-z]/g, '').toLowerCase();
if (token && PRESET_ABBREVIATIONS[token]) {
return PRESET_ABBREVIATIONS[token];
}
return derivePresetInitials(preset);
}
/**
* Generate fallback initials for unmapped presets.
*
* @param {string} preset Raw preset string.
* @returns {string|null} Derived initials.
*/
function derivePresetInitials(preset) {
if (!preset) {
return null;
}
const spaced = preset.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
const tokens = spaced
.split(/[\s_-]+/)
.map(part => part.replace(/[^A-Za-z]/g, ''))
.filter(Boolean);
if (tokens.length === 0) {
return null;
}
if (tokens.length === 1) {
const upper = tokens[0].toUpperCase();
if (upper.length >= 2) {
return upper.slice(0, 2);
}
if (upper.length === 1) {
return `${upper}?`;
}
return null;
}
const initials = tokens.map(part => part[0].toUpperCase());
if (initials.length >= 2) {
return `${initials[0]}${initials[1]}`;
}
return null;
}
/**
* Normalise the preset slot contents for the bracket display.
*
* @param {*} value Raw preset code.
* @returns {string} HTML-ready preset slot.
*/
function normalizePresetSlot(value) {
if (value == null) {
return PRESET_PLACEHOLDER;
}
const trimmed = String(value).trim().toUpperCase();
return trimmed.length > 0 ? trimmed.slice(0, 2) : PRESET_PLACEHOLDER;
}
export const __test__ = {
firstNonNull,
normalizeString,
@@ -190,5 +346,11 @@ export const __test__ = {
formatNodeAnnouncementPrefix,
normalizeFrequencySlot,
FREQUENCY_PLACEHOLDER,
formatChatChannelTag
formatChatChannelTag,
resolveModemPresetCandidate,
normalizePresetString,
abbreviatePreset,
derivePresetInitials,
normalizePresetSlot,
PRESET_PLACEHOLDER
};
@@ -0,0 +1,258 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { buildTelemetryDisplayEntries, collectTelemetryMetrics, fmtAlt } from './short-info-telemetry.js';
/**
* Field descriptors inspected when highlighting position updates.
*
* Each entry describes a label, possible property names to inspect within a
* position payload, and a formatter that converts the raw value into a string.
*
* @type {Array<{
* label: string,
* sources: Array<string>,
* formatter: (value: *) => string,
* suppressZero?: boolean
* }>}
*/
const POSITION_HIGHLIGHT_FIELDS = Object.freeze([
{ label: 'Lat', sources: ['latitude', 'lat', 'latitude_i'], formatter: value => formatCoordinate(value, 5) },
{ label: 'Lon', sources: ['longitude', 'lon', 'longitude_i'], formatter: value => formatCoordinate(value, 5) },
{ label: 'Alt', sources: ['altitude', 'alt'], formatter: value => fmtAlt(value, 'm'), suppressZero: true },
{
label: 'Accuracy',
sources: ['accuracy', 'pos_accuracy', 'position_accuracy', 'horizontal_accuracy', 'horz_accuracy'],
formatter: value => fmtAlt(value, 'm'),
suppressZero: true,
},
{ label: 'Speed', sources: ['speed', 'ground_speed', 'groundSpeed'], formatter: formatSpeed, suppressZero: true },
{ label: 'Heading', sources: ['heading', 'course', 'bearing'], formatter: formatHeading },
{ label: 'Sats', sources: ['satellites', 'sats', 'num_sats', 'numSats'], formatter: formatInteger },
]);
/**
* Convert arbitrary values to finite numbers when possible.
*
* @param {*} value Raw value.
* @returns {?number} Normalised finite number or ``null`` for invalid input.
*/
function toFiniteNumber(value) {
if (value == null || value === '') {
return null;
}
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : null;
}
/**
* Extract the first present value from ``source`` using ``keys``.
*
* @param {?Object} source Candidate container.
* @param {Array<string>} keys Ordered list of property names.
* @returns {*} First non-nullish value or ``null`` when absent.
*/
function pickFirstValueWithKey(source, keys) {
if (!source || typeof source !== 'object' || !Array.isArray(keys)) {
return null;
}
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
const value = source[key];
if (value == null || value === '') {
continue;
}
return { value, key };
}
return null;
}
/**
* Retrieve position values from the provided payload.
*
* @param {*} positionPayload Raw position record.
* @param {Array<string>} keys Candidate property names.
* @returns {{value: *, key: string}|null} First value discovered in any supported container.
*/
function extractPositionValue(positionPayload, keys) {
if (!positionPayload || typeof positionPayload !== 'object') {
return null;
}
const containers = [positionPayload];
const nestedCandidates = ['position', 'gps'];
for (const property of nestedCandidates) {
if (positionPayload[property] && typeof positionPayload[property] === 'object') {
containers.push(positionPayload[property]);
}
}
for (const container of containers) {
const result = pickFirstValueWithKey(container, keys);
if (result) {
return result;
}
}
return null;
}
/**
* Normalise raw position values based on the originating property.
*
* @param {*} value Raw value extracted from the payload.
* @param {string} [sourceKey] Property name the value originated from.
* @returns {*} Normalised value ready for formatting.
*/
function normalizePositionValue(value, sourceKey) {
if (value == null || value === '') {
return value;
}
if (typeof sourceKey === 'string' && sourceKey.endsWith('_i')) {
const numeric = toFiniteNumber(value);
return numeric == null ? value : numeric / 1_000_000;
}
if (typeof sourceKey === 'string' && ['latitude', 'longitude', 'lat', 'lon'].includes(sourceKey)) {
const numeric = toFiniteNumber(value);
if (numeric != null && Math.abs(numeric) > 180 && Math.abs(numeric) <= 180_000_000) {
return numeric / 1_000_000;
}
}
return value;
}
/**
* Format coordinate values with a configurable precision.
*
* @param {*} value Raw coordinate value.
* @param {number} [decimals=5] Decimal precision applied when formatting.
* @returns {string} Formatted coordinate string or an empty string on failure.
*/
function formatCoordinate(value, decimals = 5) {
if (value == null || value === '') {
return '';
}
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return numeric.toFixed(decimals);
}
/**
* Format velocity readings in metres per second.
*
* @param {*} value Raw speed value.
* @returns {string} Formatted speed string or an empty string when absent.
*/
function formatSpeed(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return `${numeric.toFixed(1)} m/s`;
}
/**
* Format directional readings in degrees.
*
* @param {*} value Raw heading value.
* @returns {string} Heading string or an empty string when invalid.
*/
function formatHeading(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return `${Math.round(numeric)}°`;
}
/**
* Format integer-like values such as satellite counts.
*
* @param {*} value Raw numeric input.
* @returns {string} Integer string or an empty string on failure.
*/
function formatInteger(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return String(Math.round(numeric));
}
/**
* Build highlight entries for telemetry broadcasts.
*
* The returned collection contains label/value tuples describing only the
* fields present within ``telemetryPayload``.
*
* @param {*} telemetryPayload Raw telemetry record.
* @returns {Array<{label: string, value: string}>} Highlight entries.
*/
export function formatTelemetryHighlights(telemetryPayload) {
if (!telemetryPayload || typeof telemetryPayload !== 'object') {
return [];
}
const metrics = collectTelemetryMetrics(telemetryPayload);
if (!metrics || Object.keys(metrics).length === 0) {
return [];
}
const entries = buildTelemetryDisplayEntries(metrics);
return entries.map(entry => ({ label: entry.label, value: entry.value }));
}
/**
* Build highlight entries for position broadcasts.
*
* Only non-empty values discovered in the payload are returned.
*
* @param {*} positionPayload Raw position record.
* @returns {Array<{label: string, value: string}>} Highlight entries.
*/
export function formatPositionHighlights(positionPayload) {
if (!positionPayload || typeof positionPayload !== 'object') {
return [];
}
const highlights = [];
for (const field of POSITION_HIGHLIGHT_FIELDS) {
const extracted = extractPositionValue(positionPayload, field.sources);
if (!extracted || extracted.value == null || extracted.value === '') {
continue;
}
const rawValue = normalizePositionValue(extracted.value, extracted.key);
if (field.suppressZero) {
const numeric = toFiniteNumber(rawValue);
if (numeric === 0) {
continue;
}
}
let formatted = field.formatter(rawValue);
if (formatted == null) {
continue;
}
formatted = String(formatted).trim();
if (formatted.length === 0) {
continue;
}
highlights.push({ label: field.label, value: formatted });
}
return highlights;
}
export default {
formatTelemetryHighlights,
formatPositionHighlights,
};
+414
View File
@@ -0,0 +1,414 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { extractModemMetadata } from './node-modem-metadata.js';
/**
* Highest channel index that should be represented within the tab view.
* @type {number}
*/
export const MAX_CHANNEL_INDEX = 9;
/**
* Discrete event types that can appear in the chat activity log.
*
* @type {{
* NODE_NEW: 'node-new',
* NODE_INFO: 'node-info',
* TELEMETRY: 'telemetry',
* POSITION: 'position',
* NEIGHBOR: 'neighbor'
* }}
*/
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
NODE_NEW: 'node-new',
NODE_INFO: 'node-info',
TELEMETRY: 'telemetry',
POSITION: 'position',
NEIGHBOR: 'neighbor',
MESSAGE_ENCRYPTED: 'message-encrypted'
});
/**
* Build a data model describing the content for chat tabs.
*
* Entries outside the recent activity window, encrypted messages, and
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
*
* @param {{
* nodes?: Array<Object>,
* telemetry?: Array<Object>,
* positions?: Array<Object>,
* neighbors?: Array<Object>,
* messages?: Array<Object>,
* nowSeconds: number,
* windowSeconds: number,
* maxChannelIndex?: number,
* primaryChannelFallbackLabel?: string|null
* }} params Aggregation inputs.
* @returns {{
* logEntries: Array<{ ts: number, type: string, nodeId?: string, nodeNum?: number }>,
* channels: Array<{ id: string, index: number, label: string, entries: Array<{ ts: number, message: Object }> }>
* }} Sorted tab model data.
*/
export function buildChatTabModel({
nodes = [],
telemetry = [],
positions = [],
neighbors = [],
messages = [],
nowSeconds,
windowSeconds,
maxChannelIndex = MAX_CHANNEL_INDEX,
primaryChannelFallbackLabel = null
}) {
const cutoff = (Number.isFinite(nowSeconds) ? nowSeconds : 0) - (Number.isFinite(windowSeconds) ? windowSeconds : 0);
const logEntries = [];
const channelBuckets = new Map();
const primaryChannelEnvLabel = normalisePrimaryChannelEnvLabel(primaryChannelFallbackLabel);
for (const node of nodes || []) {
if (!node) continue;
const nodeId = normaliseNodeId(node);
const nodeNum = normaliseNodeNum(node);
const firstTs = resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
if (firstTs != null && firstTs >= cutoff) {
logEntries.push({ ts: firstTs, type: CHAT_LOG_ENTRY_TYPES.NODE_NEW, node, nodeId, nodeNum });
}
const lastTs = resolveTimestampSeconds(node.last_heard ?? node.lastHeard, node.last_seen_iso ?? node.lastSeenIso);
if (lastTs != null && lastTs >= cutoff) {
logEntries.push({ ts: lastTs, type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, node, nodeId, nodeNum });
}
}
for (const telemetryEntry of telemetry || []) {
if (!telemetryEntry) continue;
const ts = resolveTimestampSeconds(
telemetryEntry.rx_time ?? telemetryEntry.rxTime ?? telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime,
telemetryEntry.rx_iso ?? telemetryEntry.rxIso ?? telemetryEntry.telemetry_time_iso ?? telemetryEntry.telemetryTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(telemetryEntry);
const nodeNum = normaliseNodeNum(telemetryEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.TELEMETRY, telemetry: telemetryEntry, nodeId, nodeNum });
}
for (const positionEntry of positions || []) {
if (!positionEntry) continue;
const ts = resolveTimestampSeconds(
positionEntry.rx_time ?? positionEntry.rxTime ?? positionEntry.position_time ?? positionEntry.positionTime,
positionEntry.rx_iso ?? positionEntry.rxIso ?? positionEntry.position_time_iso ?? positionEntry.positionTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(positionEntry);
const nodeNum = normaliseNodeNum(positionEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.POSITION, position: positionEntry, nodeId, nodeNum });
}
for (const neighborEntry of neighbors || []) {
if (!neighborEntry) continue;
const ts = resolveTimestampSeconds(neighborEntry.rx_time ?? neighborEntry.rxTime, neighborEntry.rx_iso ?? neighborEntry.rxIso);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(neighborEntry);
const nodeNum = normaliseNodeNum(neighborEntry);
const neighborId = normaliseNeighborId(neighborEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: neighborEntry, nodeId, nodeNum, neighborId });
}
for (const message of messages || []) {
if (!message) continue;
const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso);
if (ts == null || ts < cutoff) continue;
if (message.encrypted) {
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message });
continue;
}
const rawIndex = message.channel ?? message.channel_index ?? message.channelIndex;
const channelIndex = normaliseChannelIndex(rawIndex);
if (channelIndex != null && channelIndex > maxChannelIndex) {
continue;
}
const channelName = normaliseChannelName(
message.channel_name ?? message.channelName ?? message.channel_display ?? message.channelDisplay
);
const safeIndex = channelIndex != null && channelIndex >= 0 ? channelIndex : 0;
const modemPreset = safeIndex === 0 ? extractModemMetadata(message).modemPreset : null;
const labelInfo = resolveChannelLabel({
index: safeIndex,
channelName,
modemPreset,
envFallbackLabel: primaryChannelEnvLabel
});
const bucketKey = buildChannelBucketKey(safeIndex, safeIndex === 0 && labelInfo.label !== '0' ? labelInfo.label : null);
let bucket = channelBuckets.get(bucketKey);
if (!bucket) {
bucket = {
key: bucketKey,
id: buildChannelTabId(bucketKey),
index: safeIndex,
label: labelInfo.label,
entries: [],
labelPriority: labelInfo.priority,
isPrimaryFallback: bucketKey === '0'
};
channelBuckets.set(bucketKey, bucket);
} else {
const existingPriority = bucket.labelPriority ?? CHANNEL_LABEL_PRIORITY.INDEX;
if ((labelInfo.priority ?? CHANNEL_LABEL_PRIORITY.INDEX) > existingPriority) {
bucket.label = labelInfo.label;
bucket.labelPriority = labelInfo.priority;
}
}
bucket.entries.push({ ts, message });
}
logEntries.sort((a, b) => a.ts - b.ts);
let hasPrimaryBucket = false;
for (const bucket of channelBuckets.values()) {
if (bucket.index === 0) {
hasPrimaryBucket = true;
break;
}
}
if (!hasPrimaryBucket) {
const bucketKey = '0';
channelBuckets.set(bucketKey, {
key: bucketKey,
id: buildChannelTabId(bucketKey),
index: 0,
label: '0',
entries: [],
labelPriority: CHANNEL_LABEL_PRIORITY.INDEX,
isPrimaryFallback: true
});
}
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
if (a.index !== b.index) {
return a.index - b.index;
}
return a.label.localeCompare(b.label);
});
for (const channel of channels) {
channel.entries.sort((a, b) => a.ts - b.ts);
}
return { logEntries, channels };
}
/**
* Extract a canonical node identifier from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical node identifier.
*/
function normaliseNodeId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_id ?? value.nodeId ?? null;
return typeof raw === 'string' && raw.trim().length ? raw.trim() : null;
}
/**
* Extract a canonical neighbour identifier from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical neighbour identifier.
*/
function normaliseNeighborId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.neighbor_id ?? value.neighborId ?? null;
if (typeof raw === 'string' && raw.trim().length) {
return raw.trim();
}
return null;
}
/**
* Extract a finite node number from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?number} Canonical numeric identifier.
*/
function normaliseNodeNum(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_num ?? value.nodeNum ?? value.num;
if (raw == null || raw === '') return null;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Convert candidate values to timestamp seconds when possible.
*
* @param {*} numeric Numeric timestamp representation.
* @param {*} isoString ISO timestamp fallback.
* @returns {?number} Timestamp in seconds when parsing succeeds.
*/
export function resolveTimestampSeconds(numeric, isoString) {
if (numeric !== null && numeric !== undefined && numeric !== '') {
const numericValue = typeof numeric === 'number' ? numeric : Number(numeric);
if (Number.isFinite(numericValue)) {
return numericValue;
}
}
if (typeof isoString === 'string' && isoString.length) {
const parsed = Date.parse(isoString);
if (Number.isFinite(parsed)) {
return parsed / 1000;
}
}
return null;
}
/**
* Sanitise channel identifiers into bounded integers.
*
* @param {*} value Raw channel index candidate.
* @returns {?number} Non-negative integer when available.
*/
export function normaliseChannelIndex(value) {
if (value == null || value === '') {
return null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return Math.trunc(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
return Math.trunc(parsed);
}
return null;
}
/**
* Normalise channel names to trimmed display strings.
*
* @param {*} value Raw channel name candidate.
* @returns {?string} Cleaned channel label when present.
*/
export function normaliseChannelName(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return null;
}
function buildChannelBucketKey(index, primaryChannelLabel) {
const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0;
if (safeIndex === 0 && primaryChannelLabel) {
const trimmed = primaryChannelLabel.trim();
if (trimmed.length > 0 && trimmed !== '0') {
return `0::${trimmed.toLowerCase()}`;
}
}
return String(safeIndex);
}
function buildChannelTabId(bucketKey) {
if (bucketKey === '0') {
return 'channel-0';
}
const slug = slugify(bucketKey);
if (slug) {
if (slug !== '0') {
return `channel-${slug}`;
}
return `channel-${slug}-${hashChannelKey(bucketKey)}`;
}
return `channel-${hashChannelKey(bucketKey)}`;
}
function slugify(value) {
if (value == null) return '';
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
function hashChannelKey(value) {
const input = String(value ?? '');
if (!input) {
return '0';
}
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) | 0;
}
if (hash < 0) {
hash = (hash * -1) >>> 0;
}
return hash.toString(36);
}
const CHANNEL_LABEL_PRIORITY = Object.freeze({
INDEX: 0,
ENV: 1,
MODEM: 2,
NAME: 3
});
function resolveChannelLabel({ index, channelName, modemPreset, envFallbackLabel }) {
const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0;
if (safeIndex === 0) {
if (channelName) {
return { label: channelName, priority: CHANNEL_LABEL_PRIORITY.NAME };
}
if (modemPreset) {
return { label: modemPreset, priority: CHANNEL_LABEL_PRIORITY.MODEM };
}
if (envFallbackLabel) {
return { label: envFallbackLabel, priority: CHANNEL_LABEL_PRIORITY.ENV };
}
return { label: '0', priority: CHANNEL_LABEL_PRIORITY.INDEX };
}
if (channelName) {
return { label: channelName, priority: CHANNEL_LABEL_PRIORITY.NAME };
}
return { label: String(safeIndex), priority: CHANNEL_LABEL_PRIORITY.INDEX };
}
function normalisePrimaryChannelEnvLabel(value) {
const trimmed = normaliseChannelName(value);
if (!trimmed) {
return null;
}
const withoutHash = trimmed.replace(/^#+/, '').trim();
return withoutHash.length > 0 ? withoutHash : null;
}
export const __test__ = {
resolveTimestampSeconds,
normaliseChannelIndex,
normaliseChannelName
};
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 { CHAT_LOG_ENTRY_TYPES } from './chat-log-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
const BASE_SEARCH_KEYS = Object.freeze([
'node_id',
'nodeId',
'id',
'node_num',
'nodeNum',
'num',
'short_name',
'shortName',
'long_name',
'longName',
'name',
'role',
'hw_model',
'hwModel',
'neighbor_id',
'neighborId'
]);
const MESSAGE_EXTRA_KEYS = Object.freeze([
'text',
'emoji',
'channel',
'channel_index',
'channelIndex',
'channel_name',
'channelName',
'channel_display',
'channelDisplay',
'from_id',
'fromId',
'to_id',
'toId',
'reply_id',
'replyId'
]);
/**
* Normalise arbitrary input into a comparable, lower-cased string.
*
* @param {*} value User-supplied input.
* @returns {string} Trimmed, lower-cased query string.
*/
export function normaliseChatFilterQuery(value) {
if (value == null) {
return '';
}
const text = String(value).trim().toLowerCase();
return text;
}
/**
* Apply chat filtering to log entries and channel buckets.
*
* @param {{ logEntries?: Array<Object>, channels?: Array<Object> }} model Chat tab model.
* @param {*} query Filter query supplied by the user.
* @returns {{ logEntries: Array<Object>, channels: Array<Object> }} Filtered model.
*/
export function filterChatModel(model = {}, query) {
const logEntries = Array.isArray(model.logEntries) ? model.logEntries : [];
const channels = Array.isArray(model.channels) ? model.channels : [];
const normalisedQuery = normaliseChatFilterQuery(query);
if (!normalisedQuery) {
return { logEntries, channels };
}
const filteredLogs = logEntries.filter(entry => chatLogEntryMatchesQuery(entry, normalisedQuery));
const filteredChannels = channels.map(channel => ({
...channel,
entries: Array.isArray(channel.entries)
? channel.entries.filter(item => chatMessageMatchesQuery(item?.message, normalisedQuery))
: []
}));
return { logEntries: filteredLogs, channels: filteredChannels };
}
/**
* Determine whether a structured chat log entry matches the query.
*
* @param {?Object} entry Chat log entry.
* @param {string} query Normalised filter query.
* @returns {boolean} True when the entry should remain visible.
*/
export function chatLogEntryMatchesQuery(entry, query) {
if (!query) return true;
if (!entry || typeof entry !== 'object') {
return false;
}
const candidates = [];
candidates.push(...collectSearchValues(entry.node));
candidates.push(...collectSearchValues(entry.telemetry));
candidates.push(...collectSearchValues(entry.position));
candidates.push(...collectSearchValues(entry.neighbor));
candidates.push(...collectSearchValues(entry.neighborNode));
if (entry.nodeId) candidates.push(entry.nodeId);
if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum);
if (entry.neighborId) candidates.push(entry.neighborId);
if (entry.type) candidates.push(entry.type);
if (entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED) {
if (entry.message && chatMessageMatchesQuery(entry.message, query)) {
return true;
}
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.TELEMETRY) {
const telemetryHighlights = formatTelemetryHighlights(entry.telemetry || {});
candidates.push(...highlightsToStrings(telemetryHighlights));
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.POSITION) {
const positionHighlights = formatPositionHighlights(entry.position || {});
candidates.push(...highlightsToStrings(positionHighlights));
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR) {
if (entry.neighbor && entry.neighbor.neighbor_id) {
candidates.push(entry.neighbor.neighbor_id);
}
}
return candidates.some(value => valueIncludesQuery(value, query));
}
/**
* Determine whether a mesh message matches the active query.
*
* @param {?Object} message Chat message payload.
* @param {string} query Normalised filter query.
* @returns {boolean} True when the message should be shown.
*/
export function chatMessageMatchesQuery(message, query) {
if (!query) return true;
if (!message || typeof message !== 'object') {
return false;
}
const candidates = [
...collectSearchValues(message, MESSAGE_EXTRA_KEYS),
...collectSearchValues(message.node),
];
return candidates.some(value => valueIncludesQuery(value, query));
}
function highlightsToStrings(highlights) {
if (!Array.isArray(highlights)) {
return [];
}
return highlights
.map(entry => {
if (!entry || typeof entry !== 'object') {
return null;
}
const label = entry.label != null ? String(entry.label).trim() : '';
const value = entry.value != null ? String(entry.value).trim() : '';
return `${label} ${value}`.trim();
})
.filter(Boolean);
}
function collectSearchValues(source, extraKeys = []) {
if (!source || typeof source !== 'object') {
return [];
}
const values = [];
const keys = extraKeys.length ? [...BASE_SEARCH_KEYS, ...extraKeys] : BASE_SEARCH_KEYS;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
const value = source[key];
if (value == null || value === '') {
continue;
}
if (typeof value === 'object') {
continue;
}
values.push(value);
}
return values;
}
function valueIncludesQuery(value, query) {
if (!query) return true;
if (value == null) {
return false;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return false;
}
return String(value).toLowerCase().includes(query);
}
if (typeof value === 'boolean') {
return (value ? 'true' : 'false').includes(query);
}
const text = String(value).trim();
if (!text) {
return false;
}
return text.toLowerCase().includes(query);
}
export default {
normaliseChatFilterQuery,
filterChatModel,
chatLogEntryMatchesQuery,
chatMessageMatchesQuery
};
+187
View File
@@ -0,0 +1,187 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
/**
* Render an accessible tab interface within ``container``.
*
* @param {{
* document: Document,
* container: HTMLElement,
* tabs: Array<{ id: string, label: string, content: Node|null }>,
* previousActiveTabId?: string|null,
* defaultActiveTabId?: string|null
* }} options Rendering parameters.
* @returns {?string} Identifier of the active tab after rendering.
*/
export function renderChatTabs({
document,
container,
tabs,
previousActiveTabId = null,
defaultActiveTabId = null
}) {
if (!container || !document) {
return null;
}
const validTabs = Array.isArray(tabs) ? tabs.filter(Boolean) : [];
if (validTabs.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
const fragment = createFragment(document);
const tabList = document.createElement('div');
tabList.className = 'chat-tablist';
tabList.setAttribute('role', 'tablist');
const panelWrapper = document.createElement('div');
panelWrapper.className = 'chat-tabpanels';
fragment.appendChild(tabList);
fragment.appendChild(panelWrapper);
const tabElements = [];
const existingActive = container.dataset?.activeTab || null;
const activeCandidateOrder = [existingActive, previousActiveTabId, defaultActiveTabId];
let activeTabId = null;
const idSet = new Set();
for (const tab of validTabs) {
if (!tab || typeof tab.id !== 'string' || tab.id.length === 0) {
continue;
}
const uniqueId = tab.id;
if (idSet.has(uniqueId)) {
continue;
}
idSet.add(uniqueId);
const button = document.createElement('button');
button.type = 'button';
button.className = 'chat-tab';
button.classList.add('chat-tab');
button.setAttribute('role', 'tab');
button.setAttribute('id', `chat-tab-${uniqueId}`);
button.dataset.tabId = uniqueId;
button.textContent = tab.label || '';
button.setAttribute('aria-selected', 'false');
button.setAttribute('tabindex', '-1');
const panel = document.createElement('div');
panel.className = 'chat-tabpanel';
panel.classList.add('chat-tabpanel');
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('id', `chat-panel-${uniqueId}`);
panel.setAttribute('aria-labelledby', button.getAttribute('id'));
panel.hidden = true;
if (tab.content) {
panel.appendChild(tab.content);
}
tabList.appendChild(button);
panelWrapper.appendChild(panel);
tabElements.push({ id: uniqueId, button, panel });
}
if (tabElements.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
for (const candidate of activeCandidateOrder) {
if (candidate && tabElements.some(entry => entry.id === candidate)) {
activeTabId = candidate;
break;
}
}
if (!activeTabId) {
activeTabId = tabElements[0].id;
}
if (typeof container.replaceChildren === 'function') {
container.replaceChildren(fragment);
} else {
container.innerHTML = '';
container.appendChild(fragment);
}
const setActiveTab = newId => {
if (!newId) return;
let matched = false;
for (const entry of tabElements) {
const isActive = entry.id === newId;
entry.button.setAttribute('aria-selected', isActive ? 'true' : 'false');
entry.button.setAttribute('tabindex', isActive ? '0' : '-1');
if (isActive) {
entry.button.classList.add('is-active');
entry.panel.hidden = false;
matched = true;
container.dataset.activeTab = newId;
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
entry.panel.scrollTop = entry.panel.scrollHeight;
}
} else {
entry.button.classList.remove('is-active');
entry.panel.hidden = true;
}
}
if (!matched) {
container.dataset.activeTab = '';
}
};
setActiveTab(activeTabId);
for (const entry of tabElements) {
entry.button.addEventListener('click', () => {
setActiveTab(entry.id);
});
}
return container.dataset.activeTab || null;
}
/**
* Create a DOM fragment with a graceful fallback for test environments.
*
* @param {Document} document Active document instance.
* @returns {{ appendChild: Function }} Fragment-like node.
*/
function createFragment(document) {
if (document && typeof document.createDocumentFragment === 'function') {
return document.createDocumentFragment();
}
const nodes = [];
return {
childNodes: nodes,
appendChild(node) {
nodes.push(node);
return node;
}
};
}
export const __test__ = { createFragment };
+702 -75
View File
@@ -34,10 +34,15 @@ import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatNodeAnnouncementPrefix
formatNodeAnnouncementPrefix,
formatChatPresetTag
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -121,15 +126,21 @@ export function initializeApp(config) {
let allNodes = [];
/** @type {Array<Object>} */
let allNeighbors = [];
/** @type {Array<Object>} */
let allMessages = [];
/** @type {Array<Object>} */
let allTelemetryEntries = [];
/** @type {Array<Object>} */
let allPositionEntries = [];
/** @type {Map<string, Object>} */
let nodesById = new Map();
let nodesById = new Map();
let messagesById = new Map();
let nodesByNum = new Map();
const messageNodeHydrator = createMessageNodeHydrator({
fetchNodeById,
applyNodeFallback: applyNodeNameFallback,
logger: console,
});
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
const CHAT_LIMIT = 1000;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -1685,14 +1696,21 @@ export function initializeApp(config) {
*/
function rebuildNodeIndex(nodes) {
nodesById = new Map();
nodesByNum = new Map();
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const nodeId = typeof node.node_id === 'string'
const nodeIdRaw = typeof node.node_id === 'string'
? node.node_id
: (typeof node.nodeId === 'string' ? node.nodeId : null);
if (!nodeId) continue;
nodesById.set(nodeId, node);
if (nodeIdRaw) {
nodesById.set(nodeIdRaw.trim(), node);
}
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
if (Number.isFinite(nodeNum)) {
nodesByNum.set(nodeNum, node);
}
}
}
@@ -2087,20 +2105,23 @@ export function initializeApp(config) {
* @param {number} ts Unix timestamp in seconds.
* @returns {HTMLElement} Divider element.
*/
function maybeCreateDateDivider(ts) {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
function createDateDividerFactory() {
let lastChatDate = null;
return ts => {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
};
}
/**
@@ -2109,21 +2130,448 @@ export function initializeApp(config) {
* @param {Object} n Node payload.
* @returns {HTMLElement} Chat log element.
*/
function createNodeChatEntry(n) {
function createNodeChatEntry(node, timestampOverride = null) {
if (!node || typeof node !== 'object') return null;
const nodeIdRaw = pickFirstProperty([node], ['node_id', 'nodeId']);
const fallbackId = nodeIdRaw || 'Unknown node';
const longNameRaw = pickFirstProperty([node], ['long_name', 'longName']);
const longNameDisplay = longNameRaw ? String(longNameRaw) : fallbackId;
const shortNameRaw = pickFirstProperty([node], ['short_name', 'shortName']);
const shortNameDisplay = shortNameRaw ? String(shortNameRaw) : (nodeIdRaw ? nodeIdRaw.slice(-4) : null);
const roleDisplay = pickFirstProperty([node], ['role']);
const tsSeconds = timestampOverride != null
? timestampOverride
: resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
return createAnnouncementEntry({
timestampSeconds: tsSeconds,
shortName: shortNameDisplay,
longName: longNameDisplay,
role: roleDisplay,
metadataSource: node,
nodeData: node,
messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy(`New node: ${longNameDisplay}`)}`
});
}
/**
* Build a formatted suffix that enumerates highlight values.
*
* @param {Array<{label: string, value: string}>} highlights Highlight metadata entries.
* @returns {string} HTML suffix containing escaped highlight entries.
*/
function buildHighlightSuffix(highlights) {
if (!Array.isArray(highlights) || highlights.length === 0) {
return '';
}
const parts = [];
for (const entry of highlights) {
if (!entry || typeof entry !== 'object') {
continue;
}
const { label, value } = entry;
if (label == null || value == null || value === '') {
continue;
}
const labelText = String(label).trim();
const valueText = String(value).trim();
if (!labelText || !valueText) {
continue;
}
parts.push(`${escapeHtml(labelText)}: ${escapeHtml(valueText)}`);
}
if (!parts.length) {
return '';
}
return `${parts.join(', ')}`;
}
/**
* Render a non-italicised emoji span suitable for announcement entries.
*
* @param {string} symbol Emoji or short textual marker.
* @returns {string} HTML span wrapping the escaped symbol.
*/
function renderEmojiHtml(symbol) {
if (symbol == null) {
return '';
}
const trimmed = String(symbol).trim();
if (!trimmed) {
return '';
}
return `<span class="chat-entry-emoji" aria-hidden="true">${escapeHtml(trimmed)}</span>`;
}
/**
* Render chat announcement copy without italic styling.
*
* @param {string} baseText Base message content before any suffix.
* @param {string} [suffix=''] Optional HTML-safe suffix appended to the base copy.
* @returns {string} Escaped HTML span containing the announcement copy.
*/
function renderAnnouncementCopy(baseText, suffix = '') {
const safeBase = baseText != null ? String(baseText) : '';
const safeSuffix = suffix != null ? String(suffix) : '';
return `<span class="chat-entry-copy">${escapeHtml(safeBase)}${safeSuffix}</span>`;
}
function createNodeInfoChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('💾')} ${renderAnnouncementCopy('Updated node info')}`
});
}
function createTelemetryChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatTelemetryHighlights(entry?.telemetry));
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('🔋')} ${renderAnnouncementCopy('Broadcasted telemetry', highlightSuffix)}`
});
}
function createPositionChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatPositionHighlights(entry?.position));
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('📍')} ${renderAnnouncementCopy('Broadcasted position info', highlightSuffix)}`
});
}
function createNeighborChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const neighborId = entry?.neighborId ?? pickFirstProperty([entry?.neighbor], ['neighbor_id', 'neighborId']);
let neighborLabel = null;
if (neighborId) {
const trimmed = String(neighborId).trim();
if (trimmed && nodesById.has(trimmed)) {
const neighborNode = nodesById.get(trimmed);
neighborLabel = pickFirstProperty([neighborNode], ['long_name', 'longName', 'short_name', 'shortName']) ?? trimmed;
} else {
neighborLabel = trimmed;
}
}
const detail = neighborLabel ? `: ${escapeHtml(String(neighborLabel))}` : '';
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('🏘️')} ${renderAnnouncementCopy('Broadcasted neighbor info', detail)}`
});
}
function createChatLogEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
if (entry.type === CHAT_LOG_ENTRY_TYPES.NODE_NEW) {
return createNodeChatEntry(entry.node ?? resolveNodeForLogEntry(entry) ?? null, entry?.ts ?? null);
}
const context = buildDisplayContext(entry);
switch (entry.type) {
case CHAT_LOG_ENTRY_TYPES.NODE_INFO:
return createNodeInfoChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.TELEMETRY:
return createTelemetryChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.POSITION:
return createPositionChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
return createNeighborChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
return entry?.message ? createMessageChatEntry(entry.message) : null;
default:
return null;
}
}
/**
* Create a consistently formatted chat log entry for node-centric events.
*
* @param {{
* timestampSeconds: ?number,
* shortName: ?string,
* longName: ?string,
* role: ?string,
* metadataSource: Object|null,
* nodeData: Object|null,
* messageHtml: string
* }} params Rendering parameters.
* @returns {HTMLElement} Chat log element.
*/
function createAnnouncementEntry({
timestampSeconds,
shortName,
longName,
role,
metadataSource,
nodeData,
messageHtml
}) {
const div = document.createElement('div');
const ts = formatTime(new Date(n.first_heard * 1000));
div.className = 'chat-entry-node';
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
const longName = escapeHtml(n.long_name || '');
const metadata = extractChatMessageMetadata(n);
const tsDate = timestampSeconds != null ? new Date(timestampSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const metadata = extractChatMessageMetadata(metadataSource || nodeData || {});
const prefix = formatNodeAnnouncementPrefix({
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
div.innerHTML = `${prefix} ${short} <em>New node: ${longName}</em>`;
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
const longNameDisplay = longName != null ? String(longName) : '';
const shortHtml = renderShortHtml(shortName, role, longNameDisplay, nodeData || metadataSource || {});
div.className = 'chat-entry-node';
div.innerHTML = `${prefix}${presetTag} ${shortHtml} ${messageHtml}`;
return div;
}
/**
* Derive display context for a chat log entry by inspecting node payloads.
*
* @param {Object} entry Chat log entry payload.
* @returns {{
* nodeId: ?string,
* nodeNum: ?number,
* shortName: ?string,
* longName: ?string,
* role: ?string,
* metadataSource: Object|null,
* nodeData: Object|null
* }} Normalised display metadata.
*/
function buildDisplayContext(entry) {
const resolvedNode = resolveNodeForLogEntry(entry);
const candidateSources = [resolvedNode, entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]
.filter(source => source && typeof source === 'object');
const nodeId = typeof entry?.nodeId === 'string' && entry.nodeId.trim().length
? entry.nodeId.trim()
: pickFirstProperty(candidateSources, ['node_id', 'nodeId']);
const nodeNum = Number.isFinite(entry?.nodeNum)
? entry.nodeNum
: pickNumericProperty(candidateSources, ['node_num', 'nodeNum', 'num']);
let shortName = pickFirstProperty(candidateSources, ['short_name', 'shortName']);
if ((!shortName || String(shortName).trim().length === 0) && nodeId) {
shortName = nodeId.slice(-4);
}
let longName = pickFirstProperty(candidateSources, ['long_name', 'longName']);
if ((!longName || String(longName).trim().length === 0) && nodeId) {
longName = nodeId;
}
const role = pickFirstProperty(candidateSources, ['role']);
const metadataSource = resolvedNode || candidateSources[0] || {};
const nodeData = resolvedNode || candidateSources[0] || {};
return { nodeId, nodeNum, shortName, longName, role, metadataSource, nodeData };
}
/**
* Locate the canonical node object associated with a chat log entry.
*
* @param {Object} entry Chat log entry payload.
* @returns {?Object} Matched node payload when available.
*/
function resolveNodeForLogEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
if (entry.node && typeof entry.node === 'object') {
return entry.node;
}
const idCandidates = [];
if (typeof entry?.nodeId === 'string') {
idCandidates.push(entry.nodeId);
}
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
const candidate = pickFirstProperty([source], ['node_id', 'nodeId']);
if (candidate) {
idCandidates.push(candidate);
}
}
for (const rawId of idCandidates) {
const trimmed = typeof rawId === 'string' ? rawId.trim() : String(rawId);
if (trimmed && nodesById.has(trimmed)) {
return nodesById.get(trimmed);
}
}
const numCandidates = [];
if (Number.isFinite(entry?.nodeNum)) {
numCandidates.push(entry.nodeNum);
}
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
const candidate = pickNumericProperty([source], ['node_num', 'nodeNum', 'num']);
if (Number.isFinite(candidate)) {
numCandidates.push(candidate);
}
}
for (const num of numCandidates) {
if (Number.isFinite(num) && nodesByNum.has(num)) {
return nodesByNum.get(num);
}
}
return null;
}
/**
* Retrieve the first present property value from a collection of objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {*} First present non-blank value or ``null`` when absent.
*/
function pickFirstProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value == null) continue;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
continue;
}
return trimmed;
}
return value;
}
}
return null;
}
/**
* Retrieve the first finite numeric property from candidate objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {?number} First finite number when available.
*/
function pickNumericProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const raw = source[key];
if (raw == null || raw === '') continue;
const num = typeof raw === 'number' ? raw : Number(raw);
if (Number.isFinite(num)) {
return num;
}
}
}
return null;
}
/**
* Describe an encrypted message when the payload cannot be decrypted.
*
* @param {Object} message Raw message payload.
* @returns {{content: string, isHtml: boolean}} Renderable notice payload.
*/
function formatEncryptedMessageNotice(message) {
const recipient = pickFirstProperty([message], ['to_id', 'toId']);
const recipientText = recipient != null && recipient !== ''
? String(recipient).trim()
: '';
if (recipientText && recipientText.toLowerCase() !== '^all') {
const targetNode = resolveRecipientNode(recipientText);
if (targetNode) {
const badge = renderShortHtml(
targetNode.short_name ?? targetNode.shortName,
targetNode.role,
targetNode.long_name ?? targetNode.longName,
targetNode
);
const idSpan = `<span class="mono">${escapeHtml(recipientText)}</span>`;
return { content: `🔒 encrypted message to ${badge} ${idSpan}`, isHtml: true };
}
return { content: `🔒 encrypted message to ${recipientText}`, isHtml: false };
}
const channelCandidate = pickFirstProperty([message], ['channel', 'channel_index', 'channelIndex']);
let channelLabel = null;
if (channelCandidate != null && channelCandidate !== '') {
if (typeof channelCandidate === 'number' && Number.isFinite(channelCandidate)) {
channelLabel = String(Math.round(channelCandidate));
} else {
const trimmedChannel = String(channelCandidate).trim();
if (trimmedChannel.length > 0) {
const numericChannel = Number(trimmedChannel);
channelLabel = Number.isFinite(numericChannel) ? String(Math.round(numericChannel)) : trimmedChannel;
}
}
}
if (!channelLabel) {
const channelName = pickFirstProperty([message], ['channel_name', 'channelName']);
if (channelName != null) {
const trimmedName = String(channelName).trim();
if (trimmedName.length > 0) {
channelLabel = trimmedName;
}
}
}
if (!channelLabel) {
channelLabel = 'unknown channel';
}
return { content: `🔒 encrypted message on channel ${channelLabel}`, isHtml: false };
}
/**
* Resolve a recipient node using identifier or numeric references.
*
* @param {string} recipientId Target node reference.
* @returns {?Object} Node metadata when available.
*/
function resolveRecipientNode(recipientId) {
if (typeof recipientId !== 'string' || recipientId.length === 0) {
return null;
}
if (nodesById instanceof Map && nodesById.size > 0) {
const direct = nodesById.get(recipientId);
if (direct) {
return direct;
}
}
if (nodesByNum instanceof Map && nodesByNum.size > 0) {
const decimal = Number(recipientId);
if (Number.isFinite(decimal) && nodesByNum.has(decimal)) {
return nodesByNum.get(decimal);
}
if (/^0x[0-9a-f]+$/i.test(recipientId)) {
const hexValue = Number.parseInt(recipientId, 16);
if (Number.isFinite(hexValue) && nodesByNum.has(hexValue)) {
return nodesByNum.get(hexValue);
}
}
}
return null;
}
/**
* Build a chat log entry for a text message.
*
@@ -2132,70 +2580,239 @@ export function initializeApp(config) {
*/
function createMessageChatEntry(m) {
const div = document.createElement('div');
const ts = formatTime(new Date(m.rx_time * 1000));
const tsSeconds = resolveTimestampSeconds(
m.rx_time ?? m.rxTime,
m.rx_iso ?? m.rxIso
);
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
const text = escapeHtml(m.text || '');
const replyPrefix = resolveReplyPrefix({
message: m,
messagesById,
nodesById,
renderShortHtml,
escapeHtml
});
let messageBodyHtml = '';
if (m && m.encrypted) {
const notice = formatEncryptedMessageNotice(m);
if (notice && typeof notice === 'object') {
const content = notice.content ?? '';
messageBodyHtml = notice.isHtml ? content : escapeHtml(content);
} else {
messageBodyHtml = '';
}
} else {
messageBodyHtml = buildMessageBody({
message: m || {},
escapeHtml,
renderEmojiHtml
});
}
const combinedSegments = [];
if (replyPrefix) combinedSegments.push(replyPrefix);
if (messageBodyHtml) combinedSegments.push(messageBodyHtml);
const text = combinedSegments.length > 0 ? combinedSegments.join(' ') : '';
const metadata = extractChatMessageMetadata(m);
const prefix = formatChatMessagePrefix({
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const channelTag = formatChatChannelTag({
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
});
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
div.className = 'chat-entry-msg';
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
div.innerHTML = `${prefix}${presetTag} ${short} ${text}`;
return div;
}
/**
* Attach node context to chat log entries when identifier metadata exists.
*
* @param {Array<Object>} entries Chat log entries.
* @returns {Array<Object>} Enriched entries.
*/
function attachNodeContextToLogEntries(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
return Array.isArray(entries) ? entries : [];
}
return entries.map(entry => {
if (!entry || typeof entry !== 'object') {
return entry;
}
const hasNode = entry.node && typeof entry.node === 'object';
const hasNeighborNode = entry.neighborNode && typeof entry.neighborNode === 'object';
const resolvedNode = hasNode ? entry.node : resolveNodeForLogEntryContext(entry);
const resolvedNeighbor = hasNeighborNode ? entry.neighborNode : resolveNeighborForLogEntry(entry);
if (resolvedNode === entry.node && resolvedNeighbor === entry.neighborNode) {
return entry;
}
const enriched = { ...entry };
if (resolvedNode && !hasNode) {
enriched.node = resolvedNode;
}
if (resolvedNeighbor && !hasNeighborNode) {
enriched.neighborNode = resolvedNeighbor;
}
return enriched;
});
}
/**
* Locate the canonical node associated with a chat log entry for filtering.
*
* @param {Object} entry Chat log entry.
* @returns {?Object} Node payload when available.
*/
function resolveNodeForLogEntryContext(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
if (nodesById instanceof Map && typeof entry.nodeId === 'string' && nodesById.has(entry.nodeId)) {
return nodesById.get(entry.nodeId);
}
if (nodesByNum instanceof Map && Number.isFinite(entry.nodeNum) && nodesByNum.has(entry.nodeNum)) {
return nodesByNum.get(entry.nodeNum);
}
return null;
}
/**
* Locate the neighbor node metadata for a chat log entry when available.
*
* @param {Object} entry Chat log entry.
* @returns {?Object} Neighbor node payload when available.
*/
function resolveNeighborForLogEntry(entry) {
if (!entry || typeof entry !== 'object' || !(nodesById instanceof Map)) {
return null;
}
const neighborId = typeof entry.neighborId === 'string' ? entry.neighborId : null;
if (neighborId && nodesById.has(neighborId)) {
return nodesById.get(neighborId);
}
return null;
}
/**
* Render the chat history panel with nodes and messages.
*
* @param {Array<Object>} nodes Collection of node payloads.
* @param {Array<Object>} messages Collection of message payloads.
* @param {{
* nodes?: Array<Object>,
* messages?: Array<Object>,
* telemetryEntries?: Array<Object>,
* positionEntries?: Array<Object>,
* neighborEntries?: Array<Object>,
* filterQuery?: string
* }} params Render inputs.
* @returns {void}
*/
function renderChatLog(nodes, messages) {
function renderChatLog({
nodes = [],
messages = [],
telemetryEntries = [],
positionEntries = [],
neighborEntries = [],
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
const entries = [];
for (const n of nodes || []) {
entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
}
for (const m of messages || []) {
if (!m || m.encrypted) continue;
entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
}
messagesById = buildMessageIndex(messages);
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
const recentEntries = entries.filter(entry => {
if (entry == null) return false;
const rawTs = entry.ts;
if (rawTs == null) return false;
const ts = typeof rawTs === 'number' ? rawTs : Number(rawTs);
if (!Number.isFinite(ts)) return false;
entry.ts = ts;
return ts >= cutoff;
const { logEntries, channels } = buildChatTabModel({
nodes,
telemetry: telemetryEntries,
positions: positionEntries,
neighbors: neighborEntries,
messages,
nowSeconds,
windowSeconds: CHAT_RECENT_WINDOW_SECONDS,
maxChannelIndex: MAX_CHANNEL_INDEX,
primaryChannelFallbackLabel: config.channel
});
recentEntries.sort((a, b) => {
if (a.ts !== b.ts) return a.ts - b.ts;
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
const enrichedLogEntries = attachNodeContextToLogEntries(logEntries);
const { logEntries: filteredLogEntries, channels: filteredChannels } = filterChatModel(
{ logEntries: enrichedLogEntries, channels },
filterQuery
);
const logContent = buildChatFragment({
entries: filteredLogEntries,
renderEntry: createChatLogEntry,
emptyLabel: 'No recent mesh activity.'
});
const frag = document.createDocumentFragment();
lastChatDate = null;
for (const entry of recentEntries) {
const divider = maybeCreateDateDivider(entry.ts);
if (divider) frag.appendChild(divider);
if (entry.type === 'node') {
frag.appendChild(createNodeChatEntry(entry.item));
} else {
frag.appendChild(createMessageChatEntry(entry.item));
const channelTabs = filteredChannels.map(channel => ({
id: channel.id || `channel-${channel.index}`,
label: channel.label,
content: buildChatFragment({
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderEntry: entry => createMessageChatEntry(entry.item),
emptyLabel: 'No messages on this channel.'
}),
index: channel.index,
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
}));
const tabs = [
{ id: 'log', label: 'Log', content: logContent },
...channelTabs
];
const previousActive = chatEl.dataset?.activeTab || null;
const defaultActive =
channelTabs.find(tab => tab.isPrimaryFallback)?.id ||
channelTabs.find(tab => tab.index === 0)?.id ||
channelTabs[0]?.id ||
'log';
renderChatTabs({
document,
container: chatEl,
tabs,
previousActiveTabId: previousActive,
defaultActiveTabId: defaultActive
});
}
/**
* Construct a document fragment for chat entries, inserting date dividers
* and optional empty-state labels.
*
* @param {{
* entries: Array<{ ts: number, item: Object }>,
* renderEntry: Function,
* emptyLabel?: string
* }} params Fragment construction parameters.
* @returns {DocumentFragment} Populated fragment.
*/
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
const fragment = document.createDocumentFragment();
if (!entries || entries.length === 0) {
if (emptyLabel) {
const empty = document.createElement('p');
empty.className = 'chat-empty';
empty.textContent = emptyLabel;
fragment.appendChild(empty);
}
return fragment;
}
const getDivider = createDateDividerFactory();
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
for (const entry of limitedEntries) {
if (!entry || typeof entry.ts !== 'number') {
continue;
}
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
if (typeof renderEntry === 'function') {
const node = renderEntry(entry);
if (node) {
fragment.appendChild(node);
}
}
}
chatEl.replaceChildren(frag);
while (chatEl.childElementCount > CHAT_LIMIT) {
chatEl.removeChild(chatEl.firstChild);
}
chatEl.scrollTop = chatEl.scrollHeight;
return fragment;
}
/**
@@ -2953,8 +3570,8 @@ export function initializeApp(config) {
*/
function applyFilter() {
updateFilterClearVisibility();
const rawQuery = filterInput ? filterInput.value : '';
const q = rawQuery.trim().toLowerCase();
const filterQuery = filterInput ? filterInput.value : '';
const q = normaliseChatFilterQuery(filterQuery);
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
const sortedNodes = sortNodes(filteredNodes);
const nowSec = Date.now()/1000;
@@ -2963,6 +3580,14 @@ export function initializeApp(config) {
updateCount(sortedNodes, nowSec);
updateRefreshInfo(sortedNodes, nowSec);
updateSortIndicators();
renderChatLog({
nodes: allNodes,
messages: allMessages,
telemetryEntries: allTelemetryEntries,
positionEntries: allPositionEntries,
neighborEntries: allNeighbors,
filterQuery
});
}
if (filterInput) {
@@ -3021,7 +3646,9 @@ export function initializeApp(config) {
allNodes = nodes;
rebuildNodeIndex(allNodes);
const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById);
renderChatLog(nodes, chatMessages);
allMessages = Array.isArray(chatMessages) ? chatMessages : [];
allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : [];
allPositionEntries = Array.isArray(positions) ? positions : [];
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
applyFilter();
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
+319
View File
@@ -0,0 +1,319 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
/**
* Convert a value into a trimmed string or return ``null`` for blank inputs.
*
* @param {*} value Arbitrary input value.
* @returns {?string} Trimmed string when present, otherwise ``null``.
*/
function toTrimmedString(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return String(value);
}
const str = String(value).trim();
return str.length > 0 ? str : null;
}
/**
* Normalise a message identifier to a stable string key.
*
* @param {*} value Identifier candidate.
* @returns {?string} Canonical identifier.
*/
export function normaliseMessageId(value) {
const str = toTrimmedString(value);
if (!str) return null;
if (/^-?\d+$/.test(str)) {
const parsed = Number.parseInt(str, 10);
if (Number.isFinite(parsed)) {
return String(parsed);
}
}
return str;
}
/**
* Build a map of message identifiers to their payload objects.
*
* Duplicate identifiers retain the first occurrence encountered, mirroring the
* ingestion pipeline that treats message IDs as unique keys.
*
* @param {?Array<Object>} messages Message collection.
* @returns {Map<string, Object>} Identifier lookup.
*/
export function buildMessageIndex(messages) {
const index = new Map();
if (!Array.isArray(messages)) {
return index;
}
for (const message of messages) {
if (!message || typeof message !== 'object') {
continue;
}
const idValue = message.id ?? message.packet_id ?? message.packetId;
const key = normaliseMessageId(idValue);
if (!key || index.has(key)) {
continue;
}
index.set(key, message);
}
return index;
}
/**
* Return the list of identifier candidates associated with ``message``.
*
* @param {?Object} message Message payload.
* @returns {Array<string>} Identifier candidates.
*/
function candidateMessageIdentifiers(message) {
if (!message || typeof message !== 'object') {
return [];
}
const candidates = [
message.node_id ?? message.nodeId,
message.from_id ?? message.fromId,
];
const unique = [];
for (const candidate of candidates) {
const trimmed = toTrimmedString(candidate);
if (!trimmed || unique.includes(trimmed)) {
continue;
}
unique.push(trimmed);
}
return unique;
}
/**
* Resolve the node metadata associated with ``message``.
*
* @param {?Object} message Message payload.
* @param {?Map<string, Object>} nodesById Node lookup table.
* @returns {?Object} Node object when available.
*/
function deriveMessageNode(message, nodesById) {
if (message && typeof message === 'object' && message.node && typeof message.node === 'object') {
return message.node;
}
if (!(nodesById instanceof Map)) {
return null;
}
for (const identifier of candidateMessageIdentifiers(message)) {
if (nodesById.has(identifier)) {
return nodesById.get(identifier);
}
}
return null;
}
/**
* Generate a short name fallback derived from a node identifier.
*
* @param {string} identifier Node identifier string.
* @returns {?string} Short name fallback.
*/
function fallbackShortFromIdentifier(identifier) {
const trimmed = toTrimmedString(identifier);
if (!trimmed) return null;
const core = trimmed.replace(/^!+/, '');
if (core.length >= 4) {
return core.slice(-4).toUpperCase();
}
if (trimmed.length >= 4) {
return trimmed.slice(-4).toUpperCase();
}
return trimmed.toUpperCase();
}
/**
* Determine the preferred short name for a reply badge.
*
* @param {?Object} message Message payload.
* @param {?Object} node Node metadata.
* @returns {?string} Short name candidate.
*/
function deriveShortCandidate(message, node) {
const candidates = [
node?.short_name,
node?.shortName,
message?.node?.short_name,
message?.node?.shortName,
];
for (const candidate of candidates) {
const trimmed = toTrimmedString(candidate);
if (trimmed) return trimmed;
}
for (const identifier of candidateMessageIdentifiers(message)) {
const fallback = fallbackShortFromIdentifier(identifier);
if (fallback) return fallback;
}
return null;
}
/**
* Determine the preferred long name for a reply badge tooltip.
*
* @param {?Object} message Message payload.
* @param {?Object} node Node metadata.
* @returns {?string} Long name candidate.
*/
function deriveLongCandidate(message, node) {
const candidates = [
node?.long_name,
node?.longName,
message?.node?.long_name,
message?.node?.longName,
];
for (const candidate of candidates) {
const trimmed = toTrimmedString(candidate);
if (trimmed) return trimmed;
}
return null;
}
/**
* Determine the preferred role for the reply badge.
*
* @param {?Object} message Message payload.
* @param {?Object} node Node metadata.
* @returns {?string} Role candidate.
*/
function deriveRoleCandidate(message, node) {
const candidates = [
node?.role,
message?.node?.role,
];
for (const candidate of candidates) {
const trimmed = toTrimmedString(candidate);
if (trimmed) return trimmed;
}
return null;
}
/**
* Render the reply prefix for a message when the parent is known.
*
* @param {{
* message: Object,
* messagesById: Map<string, Object>,
* nodesById: Map<string, Object>,
* renderShortHtml: Function,
* escapeHtml: Function
* }} params Rendering dependencies.
* @returns {string} HTML snippet or empty string when unavailable.
*/
export function resolveReplyPrefix({
message,
messagesById,
nodesById,
renderShortHtml,
escapeHtml
}) {
if (!message || typeof message !== 'object') {
return '';
}
const hasLookup = messagesById instanceof Map;
if (!hasLookup) {
return '';
}
const replyKey = normaliseMessageId(message.reply_id ?? message.replyId);
if (!replyKey || !messagesById.has(replyKey)) {
return '';
}
if (typeof renderShortHtml !== 'function' || typeof escapeHtml !== 'function') {
return '';
}
const parent = messagesById.get(replyKey);
const node = deriveMessageNode(parent, nodesById);
const shortName = deriveShortCandidate(parent, node);
const longName = deriveLongCandidate(parent, node);
const role = deriveRoleCandidate(parent, node);
const badgeSource = node || (parent && typeof parent === 'object' ? parent.node : null) || null;
const shortHtml = renderShortHtml(shortName, role, longName, badgeSource);
if (typeof shortHtml !== 'string' || shortHtml.length === 0) {
return '';
}
const label = escapeHtml('in reply to');
return `<span class="chat-entry-reply">[${label} ${shortHtml}]</span>`;
}
/**
* Normalise an emoji candidate into a trimmed string.
*
* @param {*} value Emoji candidate.
* @returns {?string} Emoji string when valid.
*/
function normaliseEmojiValue(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return String(value);
}
const str = String(value).trim();
return str.length > 0 ? str : null;
}
/**
* Build the rendered message body containing text and optional emoji.
*
* @param {{
* message: Object,
* escapeHtml: Function,
* renderEmojiHtml: Function
* }} params Rendering dependencies.
* @returns {string} HTML snippet describing the message body.
*/
export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) {
if (typeof escapeHtml !== 'function') {
throw new TypeError('escapeHtml must be a function');
}
if (typeof renderEmojiHtml !== 'function') {
throw new TypeError('renderEmojiHtml must be a function');
}
if (!message || typeof message !== 'object') {
return '';
}
const segments = [];
if (message.text != null) {
const textString = String(message.text);
if (textString.length > 0) {
segments.push(escapeHtml(textString));
}
}
const emoji = normaliseEmojiValue(message.emoji);
if (emoji) {
segments.push(renderEmojiHtml(emoji));
}
if (segments.length === 0) {
return '';
}
return segments.join(' ');
}
@@ -280,13 +280,13 @@ export const TELEMETRY_FIELDS = [
{
key: 'channel',
label: 'Channel Util',
sources: ['channel', 'channel_utilization', 'channelUtilization'],
sources: ['channel_utilization', 'channelUtilization', 'channel'],
formatter: value => fmtTx(value),
},
{
key: 'airUtil',
label: 'Air Util Tx',
sources: ['airUtil', 'air_util_tx', 'airUtilTx'],
sources: ['air_util_tx', 'airUtilTx', 'airUtil'],
formatter: value => fmtTx(value),
},
{ key: 'temperature', label: 'Temperature', sources: ['temperature', 'temp'], formatter: fmtTemperature },
@@ -325,14 +325,27 @@ export function collectTelemetryMetrics(source) {
if (!source || typeof source !== 'object') {
return metrics;
}
const containers = [
source,
const potentialContainers = [
source.telemetry,
source.device_metrics,
source.deviceMetrics,
source.environment_metrics,
source.environmentMetrics,
source.telemetry,
].filter(container => container && typeof container === 'object');
source.raw && typeof source.raw === 'object' ? source.raw.device_metrics : null,
source.raw && typeof source.raw === 'object' ? source.raw.deviceMetrics : null,
source,
];
const containers = [];
for (const container of potentialContainers) {
if (!container || typeof container !== 'object') {
continue;
}
if (!containers.includes(container)) {
containers.push(container);
}
}
for (const field of TELEMETRY_FIELDS) {
const keys = Array.isArray(field.sources) && field.sources.length > 0
+93 -6
View File
@@ -470,9 +470,58 @@ th {
height: 60vh;
border: 1px solid #ddd;
border-radius: 8px;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px;
background: var(--bg2);
}
.chat-tablist {
display: flex;
gap: 4px;
padding: 6px 6px 0;
border-bottom: 1px solid var(--line);
}
.chat-tab {
flex: 1;
border: none;
background: transparent;
color: inherit;
padding: 6px 8px;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-size: 12px;
transition: background-color 120ms ease, color 120ms ease;
}
.chat-tab:is(:focus-visible, :hover) {
background: rgba(0, 0, 0, 0.06);
}
.chat-tab.is-active {
background: var(--bg2);
color: var(--accent);
border-bottom: 2px solid var(--accent);
}
.chat-tabpanels {
flex: 1;
display: flex;
min-height: 0;
}
.chat-tabpanel {
flex: 1;
padding: 6px;
overflow-y: auto;
}
.chat-empty {
margin: 12px 0;
color: var(--muted);
font-style: italic;
}
.chat-entry-node {
@@ -480,6 +529,16 @@ th {
color: #555;
}
.chat-entry-copy {
font-style: normal;
}
.chat-entry-reply {
color: var(--muted);
font-style: italic;
margin-right: 4px;
}
.chat-entry-msg {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
@@ -630,7 +689,7 @@ body.dark .filter-clear:hover {
outline-offset: 2px;
}
button {
button:not(.chat-tab):not(.sort-button) {
padding: 6px 10px;
border: 1px solid #ccc;
background: #fff;
@@ -649,7 +708,7 @@ button {
line-height: 1;
}
button:hover {
button:not(.chat-tab):not(.sort-button):hover {
background: #f6f6f6;
}
@@ -1101,17 +1160,41 @@ body.dark #chat {
color: #eee;
}
body.dark .chat-tablist {
border-bottom-color: rgba(255, 255, 255, 0.18);
}
body.dark .chat-tab {
color: #ddd;
background: transparent;
border-color: transparent;
}
body.dark .chat-tab:is(:focus-visible, :hover) {
background: rgba(255, 255, 255, 0.1);
}
body.dark .chat-tab.is-active {
background: #222;
color: var(--accent);
border-bottom-color: var(--accent);
}
body.dark .chat-empty {
color: #888;
}
body.dark th {
background: #222;
}
body.dark button {
body.dark button:not(.chat-tab):not(.sort-button) {
background: #333;
border-color: #444;
color: #eee;
}
body.dark button:hover {
body.dark button:not(.chat-tab):not(.sort-button):hover {
background: #444;
}
@@ -1180,6 +1263,10 @@ body.dark .chat-entry-msg {
color: #bbb;
}
body.dark .chat-entry-reply {
color: #999;
}
body.dark .short-name {
color: #555;
}
+124 -3
View File
@@ -813,6 +813,52 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe ".self_instance_domain" do
around do |example|
original_app_env = ENV["APP_ENV"]
original_rack_env = ENV["RACK_ENV"]
begin
example.run
ensure
if original_app_env
ENV["APP_ENV"] = original_app_env
else
ENV.delete("APP_ENV")
end
if original_rack_env
ENV["RACK_ENV"] = original_rack_env
else
ENV.delete("RACK_ENV")
end
end
end
it "returns the sanitized domain when configuration is present" do
ENV.delete("APP_ENV")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ") do
expect(application_class.self_instance_domain).to eq("example.org")
end
end
it "returns nil when the domain is unavailable outside production" do
ENV["APP_ENV"] = "development"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect(application_class.self_instance_domain).to be_nil
end
end
it "raises when the domain is unavailable in production" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect { application_class.self_instance_domain }.to raise_error(
RuntimeError,
"INSTANCE_DOMAIN could not be determined",
)
end
end
end
describe ".self_instance_registration_decision" do
let(:domain) { "spec.mesh.test" }
@@ -1272,7 +1318,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
before do
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
fetch_stub = lambda do |host, path|
case path
when "/.well-known/potato-mesh"
[well_known_document, URI("https://#{host}#{path}")]
@@ -1282,6 +1328,29 @@ RSpec.describe "Potato Mesh Sinatra app" do
[nil, []]
end
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
fetch_stub.call(host, path)
end
allow(PotatoMesh::Application).to receive(:fetch_instance_json) do |host, path|
fetch_stub.call(host, path)
end
allow_any_instance_of(Sinatra::Application).to receive(:enqueue_federation_crawl) do |instance, domain, per_response_limit:, overall_limit:|
db = instance.open_database
begin
instance.ingest_known_instances_from!(
db,
domain,
per_response_limit: per_response_limit,
overall_limit: overall_limit,
)
ensure
db&.close
end
true
end
end
it "stores a federated instance when validation succeeds" do
@@ -2435,7 +2504,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
rows = db.execute(<<~SQL)
SELECT id, rx_time, rx_iso, from_id, to_id, channel,
portnum, text, snr, rssi, hop_limit,
lora_freq, modem_preset, channel_name
lora_freq, modem_preset, channel_name,
reply_id, emoji
FROM messages
ORDER BY id
SQL
@@ -2457,10 +2527,53 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(row["lora_freq"]).to eq(expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
expect(row["channel_name"]).to eq(expected["channel_name"])
expect(row["reply_id"]).to eq(expected["reply_id"])
expect(row["emoji"]).to eq(expected["emoji"])
end
end
end
it "persists reply metadata and emoji reactions" do
parent_payload = {
"id" => 42,
"rx_time" => reference_time.to_i - 10,
"from_id" => "!parent",
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "source message",
}
reaction_payload = {
"id" => 108,
"rx_time" => reference_time.to_i,
"from_id" => "!reactor",
"channel" => 0,
"portnum" => "REACTION_APP",
"reply_id" => parent_payload["id"],
"emoji" => " 🔥 ",
}
post "/api/messages", parent_payload.to_json, auth_headers
expect(last_response).to be_ok
post "/api/messages", reaction_payload.to_json, auth_headers
expect(last_response).to be_ok
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row("SELECT reply_id, emoji FROM messages WHERE id = ?", [reaction_payload["id"]])
expect(row["reply_id"]).to eq(parent_payload["id"])
expect(row["emoji"]).to eq("🔥")
end
get "/api/messages"
expect(last_response).to be_ok
body = JSON.parse(last_response.body)
reaction_row = body.find { |entry| entry["id"] == reaction_payload["id"] }
expect(reaction_row).not_to be_nil
expect(reaction_row["reply_id"]).to eq(parent_payload["id"])
expect(reaction_row["emoji"]).to eq("🔥")
end
it "creates hidden nodes for unknown message senders" do
payload = {
"id" => 9_999,
@@ -3158,7 +3271,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
messages = JSON.parse(last_response.body)
expect(messages).to be_an(Array)
expect(messages).to be_empty
encrypted_entry = messages.find { |row| row["id"] == payload["packet_id"] }
expect(encrypted_entry).not_to be_nil
expect(encrypted_entry["encrypted"]).to eq(encrypted_b64)
expect(encrypted_entry["text"]).to be_nil
expect(encrypted_entry["from_id"]).to eq(sender_id)
expect(encrypted_entry["to_id"]).to eq(receiver_id)
end
it "updates node last_heard for plaintext messages" do
@@ -3518,6 +3637,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(actual_row["lora_freq"]).to eq(expected["lora_freq"])
expect(actual_row["modem_preset"]).to eq(expected["modem_preset"])
expect(actual_row["channel_name"]).to eq(expected["channel_name"])
expect(actual_row["reply_id"]).to eq(expected["reply_id"])
expect(actual_row["emoji"]).to eq(expected["emoji"])
expect(actual_row["rx_time"]).to eq(expected["rx_time"])
expect(actual_row["rx_iso"]).to eq(expected["rx_iso"])
expect(actual_row).not_to have_key("node")
+72
View File
@@ -285,6 +285,78 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".federation_worker_pool_size" do
it "returns the baked-in pool size when unset" do
within_env("FEDERATION_WORKERS" => nil) do
expect(described_class.federation_worker_pool_size).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_WORKER_POOL_SIZE,
)
end
end
it "accepts positive overrides" do
within_env("FEDERATION_WORKERS" => "9") do
expect(described_class.federation_worker_pool_size).to eq(9)
end
end
it "rejects invalid overrides" do
within_env("FEDERATION_WORKERS" => "0") do
expect(described_class.federation_worker_pool_size).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_WORKER_POOL_SIZE,
)
end
end
end
describe ".federation_worker_queue_capacity" do
it "returns the baked-in queue capacity when unset" do
within_env("FEDERATION_WORK_QUEUE" => nil) do
expect(described_class.federation_worker_queue_capacity).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY,
)
end
end
it "accepts positive overrides" do
within_env("FEDERATION_WORK_QUEUE" => "33") do
expect(described_class.federation_worker_queue_capacity).to eq(33)
end
end
it "rejects invalid overrides" do
within_env("FEDERATION_WORK_QUEUE" => "-1") do
expect(described_class.federation_worker_queue_capacity).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY,
)
end
end
end
describe ".federation_task_timeout_seconds" do
it "returns the baked-in timeout when unset" do
within_env("FEDERATION_TASK_TIMEOUT" => nil) do
expect(described_class.federation_task_timeout_seconds).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS,
)
end
end
it "accepts positive overrides" do
within_env("FEDERATION_TASK_TIMEOUT" => "47") do
expect(described_class.federation_task_timeout_seconds).to eq(47)
end
end
it "rejects invalid overrides" do
within_env("FEDERATION_TASK_TIMEOUT" => "-7") do
expect(described_class.federation_task_timeout_seconds).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS,
)
end
end
end
describe ".db_path" do
it "returns the default path inside the data directory" do
expect(described_class.db_path).to eq(described_class.default_db_path)
+217
View File
@@ -48,6 +48,23 @@ RSpec.describe PotatoMesh::App::Federation do
def reset_warn_messages
@warn_messages = []
end
def settings
@settings ||= Struct.new(
:federation_thread,
:initial_federation_thread,
:federation_worker_pool,
).new
end
def set(key, value)
writer = "#{key}="
if settings.respond_to?(writer)
settings.public_send(writer, value)
else
raise ArgumentError, "unsupported setting #{key}"
end
end
end
end
end
@@ -57,6 +74,11 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil)
federation_helpers.reset_debug_messages
federation_helpers.reset_warn_messages
federation_helpers.shutdown_federation_worker_pool!
end
after do
federation_helpers.shutdown_federation_worker_pool!
end
describe ".remote_instance_cert_store" do
@@ -476,4 +498,199 @@ RSpec.describe PotatoMesh::App::Federation do
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
end
end
describe ".ensure_federation_worker_pool!" do
before do
allow(PotatoMesh::Config).to receive(:federation_worker_pool_size).and_return(1)
allow(PotatoMesh::Config).to receive(:federation_worker_queue_capacity).and_return(1)
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.05)
end
it "returns nil when federation is disabled" do
allow(federation_helpers).to receive(:federation_enabled?).and_return(false)
expect(federation_helpers.ensure_federation_worker_pool!).to be_nil
end
it "creates and memoizes the worker pool" do
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
pool = federation_helpers.ensure_federation_worker_pool!
expect(pool).to be_a(PotatoMesh::App::WorkerPool)
expect(federation_helpers.ensure_federation_worker_pool!).to equal(pool)
ensure
pool&.shutdown(timeout: 0.05)
federation_helpers.set(:federation_worker_pool, nil)
end
end
describe ".shutdown_federation_worker_pool!" do
it "logs an error when shutdown fails" do
pool = instance_double(PotatoMesh::App::WorkerPool)
allow(pool).to receive(:shutdown).and_raise(StandardError, "boom")
federation_helpers.set(:federation_worker_pool, pool)
federation_helpers.shutdown_federation_worker_pool!
expect(federation_helpers.warn_messages.last).to include("Failed to shut down federation worker pool")
expect(federation_helpers.send(:settings).federation_worker_pool).to be_nil
end
end
describe ".enqueue_federation_crawl" do
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
it "returns false and logs when the pool is unavailable" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(nil)
result = federation_helpers.enqueue_federation_crawl(
"remote.mesh",
per_response_limit: 5,
overall_limit: 9,
)
expect(result).to be(false)
expect(federation_helpers.debug_messages.last).to include("Skipped remote instance crawl")
end
it "schedules ingestion work on the pool" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
db = instance_double(SQLite3::Database)
allow(db).to receive(:close)
expect(federation_helpers).to receive(:open_database).and_return(db)
expect(federation_helpers).to receive(:ingest_known_instances_from!).with(
db,
"remote.mesh",
per_response_limit: 5,
overall_limit: 9,
)
task = instance_double(PotatoMesh::App::WorkerPool::Task)
expect(pool).to receive(:schedule) do |&block|
block.call
task
end
result = federation_helpers.enqueue_federation_crawl(
"remote.mesh",
per_response_limit: 5,
overall_limit: 9,
)
expect(result).to be(true)
expect(db).to have_received(:close)
end
it "logs when the worker queue is saturated" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::QueueFullError, "full")
expect(federation_helpers).to receive(:warn_log).with(
"Skipped remote instance crawl",
hash_including(
context: "federation.instances",
domain: "remote.mesh",
reason: "worker queue saturated",
),
).and_call_original
result = federation_helpers.enqueue_federation_crawl(
"remote.mesh",
per_response_limit: 1,
overall_limit: 2,
)
expect(result).to be(false)
end
it "logs when the worker pool is shutting down" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::ShutdownError, "closed")
expect(federation_helpers).to receive(:warn_log).with(
"Skipped remote instance crawl",
hash_including(
context: "federation.instances",
domain: "remote.mesh",
reason: "worker pool shut down",
),
).and_call_original
result = federation_helpers.enqueue_federation_crawl(
"remote.mesh",
per_response_limit: 1,
overall_limit: 2,
)
expect(result).to be(false)
end
end
describe ".wait_for_federation_tasks" do
it "does nothing for empty input" do
federation_helpers.wait_for_federation_tasks([])
expect(federation_helpers.warn_messages).to be_empty
end
it "logs timeouts" do
task = instance_double(PotatoMesh::App::WorkerPool::Task)
allow(task).to receive(:wait).and_raise(PotatoMesh::App::WorkerPool::TaskTimeoutError, "late")
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.01)
federation_helpers.wait_for_federation_tasks([["remote.mesh", task]])
expect(federation_helpers.warn_messages.last).to include("task timed out")
end
it "logs unexpected failures" do
task = instance_double(PotatoMesh::App::WorkerPool::Task)
allow(task).to receive(:wait).and_raise(RuntimeError, "boom")
allow(PotatoMesh::Config).to receive(:federation_task_timeout_seconds).and_return(0.01)
federation_helpers.wait_for_federation_tasks([["remote.mesh", task]])
expect(federation_helpers.warn_messages.last).to include("task failed")
end
end
describe ".announce_instance_to_all_domains" do
let(:pool) { instance_double(PotatoMesh::App::WorkerPool) }
before do
allow(federation_helpers).to receive(:federation_enabled?).and_return(true)
allow(federation_helpers).to receive(:ensure_self_instance_record!).and_return([
{ domain: "self.mesh" },
"signature",
])
allow(federation_helpers).to receive(:instance_announcement_payload).and_return({})
allow(JSON).to receive(:generate).and_return("payload-json")
allow(federation_helpers).to receive(:federation_target_domains).and_return(%w[alpha.mesh beta.mesh])
end
it "schedules announcements on the worker pool" do
task = instance_double(PotatoMesh::App::WorkerPool::Task)
allow(federation_helpers).to receive(:wait_for_federation_tasks)
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
expect(pool).to receive(:schedule).twice.and_return(task)
federation_helpers.announce_instance_to_all_domains
end
it "falls back to synchronous announcements when the queue is saturated" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(pool)
allow(pool).to receive(:schedule).and_raise(PotatoMesh::App::WorkerPool::QueueFullError, "full")
allow(federation_helpers).to receive(:wait_for_federation_tasks)
expect(federation_helpers).to receive(:announce_instance_to_domain).with("alpha.mesh", "payload-json").once
expect(federation_helpers).to receive(:announce_instance_to_domain).with("beta.mesh", "payload-json").once
federation_helpers.announce_instance_to_all_domains
end
it "runs synchronously when the worker pool is unavailable" do
allow(federation_helpers).to receive(:federation_worker_pool).and_return(nil)
expect(federation_helpers).to receive(:announce_instance_to_domain).with("alpha.mesh", "payload-json")
expect(federation_helpers).to receive(:announce_instance_to_domain).with("beta.mesh", "payload-json")
federation_helpers.announce_instance_to_all_domains
end
end
end
+97
View File
@@ -0,0 +1,97 @@
# 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.
# frozen_string_literal: true
require "spec_helper"
require "timeout"
RSpec.describe PotatoMesh::App::WorkerPool do
def with_pool(size: 2, queue: 2)
pool = PotatoMesh::App::WorkerPool.new(size: size, max_queue: queue, name: "spec-pool")
yield pool
ensure
pool&.shutdown(timeout: 0.5)
end
describe "#schedule" do
it "executes jobs asynchronously and exposes their return values" do
with_pool do |pool|
task = pool.schedule { 21 + 21 }
expect(task.wait(timeout: 1)).to eq(42)
end
end
it "propagates exceptions raised by the job block" do
with_pool do |pool|
task = pool.schedule { raise ArgumentError, "boom" }
expect { task.wait(timeout: 1) }.to raise_error(ArgumentError, "boom")
end
end
it "raises an error when the queue is saturated" do
with_pool(size: 1, queue: 1) do |pool|
gate = Queue.new
first_task = pool.schedule { gate.pop; :first }
Timeout.timeout(1) do
sleep 0.01 until gate.num_waiting.positive?
end
second_task = pool.schedule { gate.pop; :second }
expect do
pool.schedule { :third }
end.to raise_error(described_class::QueueFullError)
gate << nil
gate << nil
expect(first_task.wait(timeout: 1)).to eq(:first)
expect(second_task.wait(timeout: 1)).to eq(:second)
end
end
end
describe "#shutdown" do
it "prevents new work from being scheduled" do
pool = described_class.new(size: 1, max_queue: 1, name: "spec-pool")
pool.shutdown(timeout: 0.5)
expect do
pool.schedule { :after_shutdown }
end.to raise_error(described_class::ShutdownError)
ensure
pool.shutdown(timeout: 0.5)
end
end
describe PotatoMesh::App::WorkerPool::Task do
it "raises a timeout when the job exceeds the provided deadline" do
with_pool do |pool|
task = pool.schedule { sleep 0.1; :done }
expect do
task.wait(timeout: 0.01)
end.to raise_error(PotatoMesh::App::WorkerPool::TaskTimeoutError)
expect(task.wait(timeout: 1)).to eq(:done)
end
end
it "reports completion status" do
with_pool do |pool|
task = pool.schedule { :result }
expect(task.complete?).to be(false)
expect(task.wait(timeout: 1)).to eq(:result)
expect(task.complete?).to be(true)
end
end
end
end