forked from iarv/potato-mesh
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3daadc4f68 | |||
| 6b72b1b3da | |||
| 52486d82ad | |||
| 487d618e00 | |||
| 9239805129 | |||
| 554b2abd82 | |||
| 8bb98f65d6 | |||
| 71c0f8b21e | |||
| aa2bc68544 | |||
| a8394effdc | |||
| e27d5ab53c | |||
| 6af272c01f | |||
| 03e2fe6a72 | |||
| 87b4cd79e7 | |||
| d94d75e605 | |||
| c965d05229 | |||
| ba80fac36c | |||
| 3c2c7611ee | |||
| 49e0f39ca9 | |||
| 625df7982d | |||
| 8eeb13166b | |||
| 80645990cb |
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -69,3 +69,7 @@ ai_docs/
|
||||
|
||||
# Generated credentials for the instance
|
||||
web/.config
|
||||
|
||||
# JavaScript dependencies
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
|
||||
@@ -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 Node’s 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 = ' ';
|
||||
|
||||
/**
|
||||
* HTML placeholder for missing preset abbreviations.
|
||||
* @type {string}
|
||||
*/
|
||||
const PRESET_PLACEHOLDER = ' ';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user