Compare commits

..

5 Commits

Author SHA1 Message Date
l5y 81e588e44c web: add markdown static pages (#723)
* web: add markdown static pages

* web: add tests and docker

* web: improve wording and configs

* web: add tests

* web: address review comments

* web: address review comments

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* web: address review comments

* web: address review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-08 16:42:13 +02:00
l5y 083de6418f web: fix federation for multi protocol (#722)
* web: fix federation for multi protocol

* web: fix short name emojis

* web: address review comments

* ci: fix the codeql gap

* ci: fix the codeql gap

* ci: fix the codeql gap

* ci: remove swift
2026-04-08 14:36:43 +02:00
l5y 5b9e6e3d48 data: trace analysus multi ingestor support (#721)
* data: trace analysus multi ingestor support

* address review comments
2026-04-08 11:58:32 +02:00
l5y 4a6ba38e94 chore: prepare codebase for breaking release (#718)
* chore: prepare codebase for breaking release

* docker: fix debug flug in prod matrix bridge
2026-04-08 10:51:38 +02:00
l5y 4d38ddd341 web: facelift (#716)
* web: facelift

* web: facelift

* web: facelift

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: address review comments

* web: more css magic

* web: link parsing for chat contact

* web: remove one-letter fallback for shortnames

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* web: fix fallback for shortnames

* web: address review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-07 21:38:43 +02:00
64 changed files with 2817 additions and 1020 deletions
+11 -1
View File
@@ -1,3 +1,6 @@
# Copyright © 2025-26 l5yth & contributors
# Licensed under the Apache License, Version 2.0 (see LICENSE)
#
# PotatoMesh Environment Configuration
# Copy this file to .env and customize for your setup
@@ -14,7 +17,7 @@ INSTANCE_DOMAIN="mesh.example.org"
# Generate a secure token: openssl rand -hex 32
API_TOKEN="your-secure-api-token-here"
# Meshtastic connection target (required for ingestor)
# Mesh radio connection target (required for ingestor)
# Common serial paths:
# - Linux: /dev/ttyACM0, /dev/ttyUSB0
# - macOS: /dev/cu.usbserial-*
@@ -23,6 +26,10 @@ API_TOKEN="your-secure-api-token-here"
# Bluetooth address (e.g. ED:4D:9E:95:CF:60).
CONNECTION="/dev/ttyACM0"
# Mesh protocol to use (meshtastic or meshcore)
# Default: meshtastic
PROTOCOL="meshtastic"
# =============================================================================
# SITE CUSTOMIZATION
# =============================================================================
@@ -68,6 +75,9 @@ PRIVATE=0
# Debug mode (0=off, 1=on)
DEBUG=0
# Energy saving mode — sleep between ingestion cycles (0=off, 1=on)
ENERGY_SAVING=0
# Default map zoom override
# MAP_ZOOM=15
+3 -9
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# GitHub Actions Workflows
## Workflows
@@ -10,12 +13,3 @@
- **`mobile.yml`** - Flutter mobile tests with coverage reporting
- **`release.yml`** - Tag-triggered Flutter release builds for Android and iOS
## Usage
```bash
# Build locally
docker-compose build
# Deploy
docker-compose up -d
```
+1 -1
View File
@@ -23,7 +23,7 @@ on:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
+3
View File
@@ -74,6 +74,9 @@ web/.config
node_modules/
web/node_modules/
# Operator-customised static pages (keep only the shipped default)
web/pages/*.md
# Debug symbols
ignored.txt
ignored-*.txt
+13
View File
@@ -1,7 +1,20 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# CHANGELOG
## v0.6.0
**Official multi-protocol release.** This version introduces first-class
support for both Meshtastic and MeshCore mesh protocols via the new `PROTOCOL`
environment variable. Key additions since v0.5.12:
* Feat: official MeshCore provider with BLE, TCP, and serial support
* Feat: multi-protocol awareness across web frontend, ingestor, and mobile app
* Enh: surface MeshCore role types and distinguish protocols in the UI
See v0.5.12 below for the full commit history of multi-protocol groundwork.
**Breaking changes — remove deprecated environment variable aliases:**
* Ingestor: remove `POTATOMESH_INSTANCE` env var — use `INSTANCE_DOMAIN` by @l5yth
+5 -2
View File
@@ -1,6 +1,9 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# Repository Guidelines
Keep code as modular as possible to reduce duplication and improve reusability and readability. If a module grows large, split it into a submodule structure. Prefer composing small, single-purpose units over monolithic files.
Keep code as modular as possible to reduce duplication and improve reusability and readability — this applies to tests as well as production code. If a module grows large, split it into a submodule structure. Prefer composing small, single-purpose units over monolithic files.
Make sure all tests pass for Python (`pytest`), Ruby (`rspec`), and JavaScript (`npm test`).
@@ -8,7 +11,7 @@ All code must be 100% unit tested — every line, branch, and code path must hav
All code must be 100% documented according to the language's API-doc standard (PDoc for Python, RDoc for Ruby, JSDoc for JavaScript, rustdoc for Rust, dartdoc for Dart). Documentation must be sufficient to generate complete API docs from source. In addition to API-level docs, add inline comments wherever the logic is not immediately self-evident.
New source files should have Apache v2 license headers using the exact string `Copyright © 2025-26 l5yth & contributors`.
Every file in the repository must carry an Apache v2 license notice using the exact string `Copyright © 2025-26 l5yth & contributors`. **Source-code files** (`.rb`, `.py`, `.js`, `.rs`, `.dart`, etc.) must include the full Apache v2 license header block. **Non-source files** (docs, configs, YAML, TOML, Dockerfiles, etc.) must include a short 2-line Apache v2 notice (copyright line + license reference).
Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code formatting.
+15
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# PotatoMesh Docker Guide
PotatoMesh publishes ready-to-run container images to the GitHub Packages container
@@ -78,6 +81,18 @@ the container. This path stores the instance private key and staged
of container lifecycle events, generated credentials are not replaced on reboot
or re-deploy.
The `potatomesh_pages` volume mounts to `/app/pages` and holds operator-managed
Markdown files that are rendered as static content pages in the web UI. On first
start the default `1-about.md` page is copied from the image into the volume.
You can add, edit, or remove `.md` files in this volume to customise your
instance's navigation. To use a host directory instead of a named volume, replace
the volume entry with a bind mount:
```yaml
volumes:
- ./my-pages:/app/pages
```
## Start the stack
From the directory containing the Compose file:
+30 -9
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.6
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -25,6 +26,9 @@ ENV BUNDLE_FORCE_RUBY_PLATFORM=true
# Install build dependencies and SQLite3
RUN apk add --no-cache \
build-base \
python3 \
py3-pip \
py3-virtualenv \
sqlite-dev \
linux-headers \
pkgconfig
@@ -40,11 +44,16 @@ RUN bundle config set --local force_ruby_platform true && \
bundle config set --local without 'development test' && \
bundle install --jobs=4 --retry=3
# Install Meshtastic decoder dependencies in a dedicated venv
RUN python3 -m venv /opt/meshtastic-venv && \
/opt/meshtastic-venv/bin/pip install --no-cache-dir meshtastic protobuf
# Production stage
FROM ruby:3.3-alpine AS production
# Install runtime dependencies
RUN apk add --no-cache \
python3 \
sqlite \
tzdata \
curl
@@ -58,18 +67,27 @@ WORKDIR /app
# Copy installed gems from builder stage
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /opt/meshtastic-venv /opt/meshtastic-venv
# Copy application code (exclude Dockerfile from web directory)
COPY --chown=potatomesh:potatomesh web/app.rb web/app.sh web/Gemfile web/Gemfile.lock* web/spec/ ./
# Copy application code (excluding the Dockerfile which is not required at runtime)
COPY --chown=potatomesh:potatomesh web/app.rb ./
COPY --chown=potatomesh:potatomesh web/app.sh ./
COPY --chown=potatomesh:potatomesh web/Gemfile ./
COPY --chown=potatomesh:potatomesh web/Gemfile.lock* ./
COPY --chown=potatomesh:potatomesh web/lib ./lib
COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views/ ./views/
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/
COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/data/mesh_ingestor/decode_payload.py
# Create data directory for SQLite database
RUN mkdir -p /app/data /app/.local/share/potato-mesh && \
chown -R potatomesh:potatomesh /app/data /app/.local
# Create data and configuration directories with correct ownership
RUN mkdir -p /app/.local/share/potato-mesh \
&& mkdir -p /app/.config/potato-mesh/well-known \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config
# Switch to non-root user
USER potatomesh
@@ -78,13 +96,16 @@ USER potatomesh
EXPOSE 41447
# Default environment variables (can be overridden by host)
ENV APP_ENV=production \
RACK_ENV=production \
ENV RACK_ENV=production \
APP_ENV=production \
MESHTASTIC_PYTHON=/opt/meshtastic-venv/bin/python \
XDG_DATA_HOME=/app/.local/share \
XDG_CONFIG_HOME=/app/.config \
SITE_NAME="PotatoMesh Demo" \
INSTANCE_DOMAIN="potato.example.com" \
CHANNEL="#LongFast" \
FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0
+3
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# Prometheus Monitoring for PotatoMesh
PotatoMesh exposes runtime telemetry through a dedicated Prometheus endpoint so you can
+29 -4
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# 🥔 PotatoMesh
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/l5yth/potato-mesh/ruby.yml?branch=main)](https://github.com/l5yth/potato-mesh/actions)
@@ -30,7 +33,7 @@ _No MQTT clutter, just local LoRa aether._
Live demo for Berlin: [potatomesh.net](https://potatomesh.net)
![screenshot of the fourth version](./scrot-0.4.png)
![screenshot of the sixth version](./scrot-0.7.png)
## Web App
@@ -125,6 +128,28 @@ well-known document is staged in
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### Custom Pages
Instance operators can publish static content pages (contact details, mesh
protocol information, legal notices, etc.) by placing Markdown files in the
`pages/` directory inside `web/`. Each `.md` file automatically becomes a nav
entry and a route under `/pages/<slug>`.
Files are named `<sort-prefix>-<slug>.md` — the numeric prefix controls
navigation order and the slug becomes the URL path and nav label:
| Filename | Nav Label | URL |
| ---------------------- | -------------- | ----------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `20-impressum.md` | Impressum | `/pages/impressum` |
A default `1-about.md` ships with the app. In Docker deployments the directory
is exposed as the `potatomesh_pages` volume (mounted at `/app/pages`) so you can
add or edit pages without rebuilding the image. The pages directory can also be
overridden with the `PAGES_DIR` environment variable.
### Federation
PotatoMesh instances can optionally federate by publishing signed metadata and
@@ -275,9 +300,9 @@ docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-arm64:latest
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-armv7:latest
# version-pinned examples
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.5.5
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.5.5
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.5.5
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.6.0
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.6.0
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.6.0
```
Note: `latest` is only published for non-prerelease versions. Pre-release tags
+6 -2
View File
@@ -1,6 +1,10 @@
# Meshtastic Reader
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
Meshtastic Reader read-only PotatoMesh chat client for Android and iOS.
# PotatoMesh Mobile
PotatoMesh Mobile — read-only mesh chat client for Android and iOS.
Supports Meshtastic and MeshCore networks.
## Setup
+2
View File
@@ -50,6 +50,7 @@ USER potatomesh
ENV CONNECTION=/dev/ttyACM0 \
CHANNEL_INDEX=0 \
DEBUG=0 \
PROTOCOL=meshtastic \
ALLOWED_CHANNELS="" \
HIDDEN_CHANNELS="" \
INSTANCE_DOMAIN="" \
@@ -77,6 +78,7 @@ USER ContainerUser
ENV CONNECTION=/dev/ttyACM0 \
CHANNEL_INDEX=0 \
DEBUG=0 \
PROTOCOL=meshtastic \
ALLOWED_CHANNELS="" \
HIDDEN_CHANNELS="" \
INSTANCE_DOMAIN="" \
+3
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
## Mesh ingestor contracts (stable interfaces)
This repos ingestion pipeline is split into:
+1
View File
@@ -70,6 +70,7 @@ _CONFIG_ATTRS = {
"CHANNEL_INDEX",
"DEBUG",
"INSTANCE",
"INSTANCES",
"API_TOKEN",
"ALLOWED_CHANNELS",
"HIDDEN_CHANNELS",
+80 -2
View File
@@ -129,6 +129,11 @@ def _resolve_instance_domain() -> str:
Reads the :envvar:`INSTANCE_DOMAIN` variable. When the value does not
contain a scheme, ``https://`` is prepended automatically.
.. note::
Kept for backward compatibility with existing tests and callers.
New code should use :func:`_resolve_instance_domains` instead.
"""
configured_instance = os.environ.get("INSTANCE_DOMAIN", "").rstrip("/")
@@ -139,8 +144,80 @@ def _resolve_instance_domain() -> str:
return configured_instance
INSTANCE = _resolve_instance_domain()
API_TOKEN = os.environ.get("API_TOKEN", "")
def _normalise_domain(raw: str) -> str:
"""Strip whitespace and trailing slashes, prepend ``https://`` when needed.
Parameters:
raw: Single domain string to normalise.
Returns:
A URL string with a scheme prefix.
"""
domain = raw.strip().rstrip("/")
if domain and "://" not in domain:
return f"https://{domain}"
return domain
def _resolve_instance_domains() -> tuple[tuple[str, str], ...]:
"""Parse :envvar:`INSTANCE_DOMAIN` and :envvar:`API_TOKEN` into paired tuples.
When ``INSTANCE_DOMAIN`` contains comma-separated values, each entry is
treated as an independent target. ``API_TOKEN`` is either broadcast to
every target (single value) or positionally paired (comma-separated with
a matching count).
Returns:
A tuple of ``(instance_url, api_token)`` pairs, deduplicated by URL.
Raises:
ValueError: When the number of comma-separated tokens exceeds the
number of domains.
"""
raw_domain = os.environ.get("INSTANCE_DOMAIN", "")
raw_token = os.environ.get("API_TOKEN", "")
domains: list[str] = []
seen: set[str] = set()
for part in raw_domain.split(","):
normalised = _normalise_domain(part)
if not normalised:
continue
key = normalised.casefold()
if key in seen:
continue
seen.add(key)
domains.append(normalised)
if not domains:
return ()
tokens = [t.strip() for t in raw_token.split(",")]
# A single token (including empty string) is broadcast to all domains.
if len(tokens) == 1:
token = tokens[0]
return tuple((d, token) for d in domains)
if len(tokens) != len(domains):
raise ValueError(
f"API_TOKEN has {len(tokens)} comma-separated values but "
f"INSTANCE_DOMAIN has {len(domains)}; counts must match or "
f"API_TOKEN must be a single value"
)
return tuple(zip(domains, tokens))
INSTANCES: tuple[tuple[str, str], ...] = _resolve_instance_domains()
"""Paired ``(instance_url, api_token)`` tuples derived from the environment."""
INSTANCE = INSTANCES[0][0] if INSTANCES else _resolve_instance_domain()
"""First configured instance URL, kept for backward compatibility."""
API_TOKEN = INSTANCES[0][1] if INSTANCES else os.environ.get("API_TOKEN", "")
"""API token for the first configured instance, kept for backward compatibility."""
ENERGY_SAVING = os.environ.get("ENERGY_SAVING") == "1"
"""When ``True``, enables the ingestor's energy saving mode."""
@@ -202,6 +279,7 @@ __all__ = [
"HIDDEN_CHANNELS",
"ALLOWED_CHANNELS",
"INSTANCE",
"INSTANCES",
"API_TOKEN",
"ENERGY_SAVING",
"LORA_FREQ",
+6 -1
View File
@@ -666,11 +666,16 @@ def main(*, provider: MeshProtocol | None = None) -> None:
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
instance_label = (
", ".join(inst for inst, _ in config.INSTANCES)
if config.INSTANCES
else "(no INSTANCE_DOMAIN configured)"
)
config._debug_log(
"Mesh daemon starting",
context="daemon.main",
severity="info",
target=config.INSTANCE or "(no INSTANCE_DOMAIN configured)",
target=instance_label,
port=config.CONNECTION or "auto",
channel=config.CHANNEL_INDEX,
)
+21 -14
View File
@@ -35,8 +35,8 @@ Connection type is detected automatically from the target string:
Node identities are derived from the first four bytes (eight hex characters)
of each contact's 32-byte public key, formatted as ``!xxxxxxxx`` to match
the canonical node-ID schema used across the system. Ingested
``user.shortName`` is the first four hex digits of that key (two bytes),
not the advertised name.
``user.shortName`` is the first two bytes (four hex characters) of the
node ID, not the advertised name.
"""
from __future__ import annotations
@@ -165,23 +165,28 @@ def _meshcore_node_id(public_key_hex: str | None) -> str | None:
return "!" + public_key_hex[:8].lower()
def _meshcore_short_name(public_key_hex: str | None) -> str:
"""Return the first four hex digits of a MeshCore public key as short name.
def _meshcore_short_name(node_id: str | None) -> str:
"""Derive a four-character short name from a canonical node ID.
Meshtastic-style ``shortName`` fields are four characters wide; MeshCore
ingest uses the leading two bytes of the 32-byte public key in lowercase
hex so the label is stable and unique per key prefix.
Uses the first two bytes (four hex characters) of the ``!xxxxxxxx`` node
ID. This keeps the short name consistent with the node ID itself if the
node ID is later replaced when the real public key is heard, the short name
will update alongside it.
Parameters:
public_key_hex: Full public key as a hex string from the MeshCore API.
node_id: Canonical ``!xxxxxxxx`` node ID string (as returned by
:func:`_meshcore_node_id`).
Returns:
Four lowercase hex characters (e.g. ``"aabb"``), or an empty string
when the key is missing or shorter than four hex characters.
Four lowercase hex characters (e.g. ``"cafe"``), or an empty string
when the node ID is missing or too short.
"""
if not public_key_hex or len(public_key_hex) < 4:
if not node_id:
return ""
return public_key_hex[:4].lower()
raw = node_id.lstrip("!")
if len(raw) < 4:
return ""
return raw[:4].lower()
def _meshcore_adv_type_to_role(adv_type: object) -> str | None:
@@ -324,6 +329,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
Node dict compatible with the ``POST /api/nodes`` payload format.
"""
pub_key = contact.get("public_key", "")
node_id = _meshcore_node_id(pub_key)
name = (contact.get("adv_name") or "").strip()
role = _meshcore_adv_type_to_role(contact.get("type"))
node: dict = {
@@ -331,7 +337,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
"protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
"shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
@@ -377,13 +383,14 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
"""
name = (self_info.get("name") or "").strip()
pub_key = self_info.get("public_key", "")
node_id = _meshcore_node_id(pub_key)
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
node: dict = {
"lastHeard": int(time.time()),
"protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
"shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
+51 -13
View File
@@ -97,29 +97,24 @@ class QueueState:
STATE = QueueState()
def _post_json(
def _send_single(
instance: str,
api_token: str,
path: str,
payload: dict,
*,
instance: str | None = None,
api_token: str | None = None,
) -> None:
"""Send a JSON payload to the configured web API.
"""Transmit a single JSON payload to one instance.
Parameters:
path: API path relative to the configured instance root.
instance: Base URL of the target instance.
api_token: Bearer token for this instance (may be empty).
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
instance: Optional override for :data:`config.INSTANCE`.
api_token: Optional override for :data:`config.API_TOKEN`.
"""
if instance is None:
instance = config.INSTANCE
if api_token is None:
api_token = config.API_TOKEN
if not instance:
return
url = f"{instance}{path}"
data = json.dumps(payload).encode("utf-8")
@@ -155,6 +150,49 @@ def _post_json(
)
def _post_json(
path: str,
payload: dict,
*,
instance: str | None = None,
api_token: str | None = None,
) -> None:
"""Send a JSON payload to one or more configured web API instances.
When ``instance`` is provided explicitly the payload is sent to that
single target. Otherwise every ``(url, token)`` pair in
:data:`config.INSTANCES` receives the payload independently so that
one failure does not block delivery to the remaining targets.
Parameters:
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
instance: Optional single-instance override.
api_token: Optional token override (only used with ``instance``).
"""
if instance is not None:
if not instance:
return
_send_single(instance, api_token or "", path, payload)
return
targets: tuple[tuple[str, str], ...] = config.INSTANCES
if not targets:
# Backward-compatible fallback for callers that only set
# config.INSTANCE / config.API_TOKEN directly.
inst = config.INSTANCE
if not inst:
return
_send_single(inst, api_token or config.API_TOKEN, path, payload)
return
for inst, token in targets:
if not inst:
continue
_send_single(inst, token, path, payload)
def _enqueue_post_json(
path: str,
payload: dict,
+18
View File
@@ -49,3 +49,21 @@ services:
environment:
DEBUG: 0
restart: always
matrix-bridge:
build:
context: .
dockerfile: matrix/Dockerfile
target: runtime
environment:
DEBUG: 0
restart: always
matrix-bridge-bridge:
build:
context: .
dockerfile: matrix/Dockerfile
target: runtime
environment:
DEBUG: 0
restart: always
+5
View File
@@ -34,6 +34,7 @@ x-web-base: &web-base
- potatomesh_data:/app/.local/share/potato-mesh
- potatomesh_config:/app/.config/potato-mesh
- potatomesh_logs:/app/logs
- potatomesh_pages:/app/pages
restart: unless-stopped
deploy:
resources:
@@ -54,6 +55,8 @@ x-ingestor-base: &ingestor-base
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN:-http://web:41447}
DEBUG: ${DEBUG:-0}
PROTOCOL: ${PROTOCOL:-meshtastic}
ENERGY_SAVING: ${ENERGY_SAVING:-0}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
volumes:
@@ -158,6 +161,8 @@ volumes:
driver: local
potatomesh_logs:
driver: local
potatomesh_pages:
driver: local
potatomesh_matrix_bridge_state:
driver: local
+4 -1
View File
@@ -1,3 +1,6 @@
<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
# potatomesh-matrix-bridge
A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix** room.
@@ -90,7 +93,7 @@ room_id = "!yourroomid:example.org"
[state]
# Where to persist last seen message id
state_file = "bridge_state.json"
````
```
The `hs_token` is used to validate inbound appservice transactions. Keep it identical in `Config.toml` and your Matrix appservice registration file.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+78
View File
@@ -96,6 +96,84 @@ class TestParseHiddenChannels:
# ---------------------------------------------------------------------------
class TestResolveInstanceDomains:
"""Tests for :func:`config._resolve_instance_domains`."""
def test_single_domain(self, monkeypatch):
"""Single domain produces one-element tuple."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld")
monkeypatch.setenv("API_TOKEN", "secret")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "secret"),)
def test_multi_domain_broadcast_token(self, monkeypatch):
"""Multiple domains with a single token broadcast the token."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, bar.tld")
monkeypatch.setenv("API_TOKEN", "shared")
result = config._resolve_instance_domains()
assert result == (
("https://foo.tld", "shared"),
("https://bar.tld", "shared"),
)
def test_multi_domain_per_instance_tokens(self, monkeypatch):
"""Comma-separated tokens are positionally paired with domains."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "tok1,tok2")
result = config._resolve_instance_domains()
assert result == (("https://a.tld", "tok1"), ("https://b.tld", "tok2"))
def test_token_count_mismatch_raises(self, monkeypatch):
"""Mismatched counts raise ValueError at parse time."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "t1,t2,t3")
with pytest.raises(ValueError, match="counts must match"):
config._resolve_instance_domains()
def test_deduplicates_domains(self, monkeypatch):
"""Duplicate domains are collapsed to a single entry."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, foo.tld")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "tok"),)
def test_preserves_explicit_scheme(self, monkeypatch):
"""Domains with explicit schemes keep them; others get https://."""
monkeypatch.setenv("INSTANCE_DOMAIN", "http://local:41447,bar.tld")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (
("http://local:41447", "tok"),
("https://bar.tld", "tok"),
)
def test_empty_domain(self, monkeypatch):
"""Empty INSTANCE_DOMAIN returns an empty tuple."""
monkeypatch.setenv("INSTANCE_DOMAIN", "")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == ()
def test_strips_trailing_slashes(self, monkeypatch):
"""Trailing slashes are stripped from domains."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld/")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "tok"),)
def test_empty_token_broadcast(self, monkeypatch):
"""Empty API_TOKEN broadcasts empty string to all instances."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "")
result = config._resolve_instance_domains()
assert result == (("https://a.tld", ""), ("https://b.tld", ""))
# ---------------------------------------------------------------------------
# _resolve_instance_domain (legacy, kept for backward compatibility)
# ---------------------------------------------------------------------------
class TestResolveInstanceDomain:
"""Tests for :func:`config._resolve_instance_domain`."""
+6
View File
@@ -233,7 +233,9 @@ def test_instance_domain_prefers_primary_env(mesh_module, monkeypatch):
monkeypatch.setenv("INSTANCE_DOMAIN", "https://new.example")
try:
refreshed_instances = mesh_module.config._resolve_instance_domains()
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCES = refreshed_instances
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
@@ -241,6 +243,7 @@ def test_instance_domain_prefers_primary_env(mesh_module, monkeypatch):
assert mesh_module.INSTANCE == "https://new.example"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
mesh_module.config.INSTANCES = mesh_module.config._resolve_instance_domains()
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
@@ -251,7 +254,9 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
monkeypatch.setenv("INSTANCE_DOMAIN", "mesh.example.org")
try:
refreshed_instances = mesh_module.config._resolve_instance_domains()
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCES = refreshed_instances
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
@@ -259,6 +264,7 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
assert mesh_module.INSTANCE == "https://mesh.example.org"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
mesh_module.config.INSTANCES = mesh_module.config._resolve_instance_domains()
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
+9 -8
View File
@@ -661,21 +661,22 @@ def test_meshcore_node_id_none_on_empty():
assert _meshcore_node_id(None) is None # type: ignore[arg-type]
def test_meshcore_short_name_first_four_hex_digits():
"""_meshcore_short_name returns the first four hex chars, lowercased."""
assert _meshcore_short_name("AABBccdd" + "00" * 28) == "aabb"
def test_meshcore_short_name_first_two_bytes_of_node_id():
"""_meshcore_short_name returns the first four hex chars of the node ID."""
assert _meshcore_short_name("!aabbccdd") == "aabb"
assert _meshcore_short_name("!AABBccdd") == "aabb"
def test_meshcore_short_name_empty_when_too_short():
"""_meshcore_short_name returns '' when the key has fewer than four hex digits."""
"""_meshcore_short_name returns '' when the node ID is missing or too short."""
assert _meshcore_short_name("") == ""
assert _meshcore_short_name("abc") == ""
assert _meshcore_short_name("!ab") == ""
assert _meshcore_short_name(None) == "" # type: ignore[arg-type]
def test_meshcore_short_name_exactly_four_chars():
"""_meshcore_short_name with exactly four hex chars returns those four chars."""
assert _meshcore_short_name("abcd") == "abcd"
def test_meshcore_short_name_without_bang_prefix():
"""_meshcore_short_name handles node IDs without the leading '!' prefix."""
assert _meshcore_short_name("cafef00d") == "cafe"
# ---------------------------------------------------------------------------
+124 -35
View File
@@ -53,6 +53,19 @@ def _fresh_state() -> QueueState:
return QueueState()
class _FakeResp:
"""Minimal context-manager response stub for ``urlopen`` patches."""
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
# ---------------------------------------------------------------------------
# Priority constant ordering
# ---------------------------------------------------------------------------
@@ -85,33 +98,24 @@ class TestPostJson:
"""Tests for :func:`queue._post_json`."""
def test_skips_when_no_instance(self, monkeypatch):
"""Does nothing when INSTANCE is empty."""
"""Does nothing when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "")
sent = []
with patch("urllib.request.urlopen") as mock_open:
_post_json("/api/test", {"key": "val"})
mock_open.assert_not_called()
def test_sends_json_post(self, monkeypatch):
"""Sends a POST request with JSON body and correct headers."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", "tok"),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "tok")
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/nodes", {"a": 1})
@@ -124,6 +128,7 @@ class TestPostJson:
def test_handles_network_error_gracefully(self, monkeypatch, capsys):
"""Network errors are caught and logged, not raised."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", ""),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "")
monkeypatch.setattr(config, "DEBUG", True)
@@ -140,19 +145,9 @@ class TestPostJson:
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {}, instance="http://override")
@@ -161,24 +156,15 @@ class TestPostJson:
def test_no_auth_header_when_token_empty(self, monkeypatch):
"""No Authorization header is added when API_TOKEN is empty."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", ""),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "")
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {})
@@ -394,3 +380,106 @@ class TestClearPostQueue:
state = _fresh_state()
_clear_post_queue(state=state)
assert state.queue == []
# ---------------------------------------------------------------------------
# Multi-instance fan-out
# ---------------------------------------------------------------------------
class TestMultiInstanceFanOut:
"""Tests for multi-instance POST fan-out in :func:`queue._post_json`."""
def test_fans_out_to_all_instances(self, monkeypatch):
"""Each configured instance receives the payload."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://alpha", "t1"), ("http://beta", "t2")),
)
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/nodes", {"a": 1})
assert len(captured) == 2
urls = {r.get_full_url() for r in captured}
assert urls == {"http://alpha/api/nodes", "http://beta/api/nodes"}
tokens = {r.get_header("Authorization") for r in captured}
assert tokens == {"Bearer t1", "Bearer t2"}
def test_failure_isolation(self, monkeypatch):
"""A failure on one instance does not prevent delivery to the next."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://broken", "t1"), ("http://ok", "t2")),
)
monkeypatch.setattr(config, "DEBUG", False)
captured = []
def fake_urlopen(req, timeout=None):
if "broken" in req.get_full_url():
raise OSError("connection refused")
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {"x": 1})
assert len(captured) == 1
assert "http://ok" in captured[0].get_full_url()
def test_explicit_instance_skips_fanout(self, monkeypatch):
"""Passing instance= explicitly bypasses the INSTANCES fan-out."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://a", "t1"), ("http://b", "t2")),
)
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {}, instance="http://override")
assert len(captured) == 1
assert "http://override" in captured[0].get_full_url()
def test_empty_instances_noop(self, monkeypatch):
"""No requests are made when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "")
with patch("urllib.request.urlopen") as mock_open:
_post_json("/api/test", {})
mock_open.assert_not_called()
def test_backward_compat_fallback(self, monkeypatch):
"""Falls back to config.INSTANCE when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "http://legacy")
monkeypatch.setattr(config, "API_TOKEN", "tok")
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {"v": 1})
assert len(captured) == 1
assert "http://legacy" in captured[0].get_full_url()
assert captured[0].get_header("Authorization") == "Bearer tok"
+3 -1
View File
@@ -76,6 +76,7 @@ COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
COPY --chown=potatomesh:potatomesh web/pages ./pages
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/
@@ -84,7 +85,8 @@ COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/dat
# Create data and configuration directories with correct ownership
RUN mkdir -p /app/.local/share/potato-mesh \
&& mkdir -p /app/.config/potato-mesh/well-known \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config
&& mkdir -p /app/pages \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config /app/pages
# Switch to non-root user
USER potatomesh
+4
View File
@@ -20,6 +20,8 @@ gem "sqlite3", "~> 1.7"
gem "rackup", "~> 2.2"
gem "puma", "~> 7.0"
gem "prometheus-client"
gem "kramdown", "~> 2.4"
gem "kramdown-parser-gfm", "~> 1.1"
group :test do
gem "rspec", "~> 3.12"
@@ -29,3 +31,5 @@ group :test do
gem "simplecov_json_formatter", "~> 0.1", require: false
gem "rspec_junit_formatter", "~> 0.6", require: false
end
gem "sanitize", "7.0.0"
+4
View File
@@ -57,6 +57,7 @@ require_relative "application/meshtastic/cipher"
require_relative "application/meshtastic/payload_decoder"
require_relative "application/data_processing"
require_relative "application/filesystem"
require_relative "application/pages"
require_relative "application/instances"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
@@ -74,6 +75,7 @@ module PotatoMesh
extend App::Queries
extend App::DataProcessing
extend App::Filesystem
extend App::Pages
helpers App::Helpers
include App::Database
@@ -85,6 +87,7 @@ module PotatoMesh
include App::Queries
include App::DataProcessing
include App::Filesystem
include App::Pages
register App::Routes::Api
register App::Routes::Ingest
@@ -210,6 +213,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL
PotatoMesh::App::Prometheus,
PotatoMesh::App::Queries,
PotatoMesh::App::DataProcessing,
PotatoMesh::App::Pages,
].each do |mod|
Object.include(mod) unless Object < mod
end
@@ -297,9 +297,12 @@ module PotatoMesh
def shutdown_federation_background_work!(timeout: nil)
request_federation_shutdown!
timeout_value = timeout || PotatoMesh::Config.federation_shutdown_timeout_seconds
# Drain the worker pool first so federation threads blocked in
# wait_for_federation_tasks unblock promptly instead of waiting
# for each task's individual timeout to expire.
shutdown_federation_worker_pool!
stop_federation_thread!(:initial_federation_thread, timeout: timeout_value)
stop_federation_thread!(:federation_thread, timeout: timeout_value)
shutdown_federation_worker_pool!
clear_federation_crawl_state!
end
@@ -86,10 +86,9 @@ module PotatoMesh
# space keeps the badge at four visual columns.
# 2. If the long name contains two or more whitespace-separated words, use
# the capitalised first letters of the first two words: ``" XY "``.
# 3. If the long name is a single word, use its capitalised first letter:
# ``" A "``.
# 4. Return +nil+ when no short name can be derived (blank input, or a
# word without extractable characters).
# 3. Return +nil+ — single-word names fall back to the raw short name stored
# in the database (typically the first two bytes of the node ID). A single
# initial looked poor and carried no more information than the raw value.
#
# @param long_name [String, nil] long name stored on the node.
# @return [String, nil] derived display short name or +nil+.
@@ -111,8 +110,7 @@ module PotatoMesh
return " #{first}#{second} " if first && second
end
letter = words[0][0]&.upcase
letter ? " #{letter} " : nil
nil
end
# Recursively coerce hash keys to strings and normalise nested arrays.
+226
View File
@@ -0,0 +1,226 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "kramdown"
require "kramdown-parser-gfm"
require "sanitize"
module PotatoMesh
module App
# Discovers, parses, and renders operator-managed Markdown pages from the
# configured pages directory. Files are named with an optional numeric
# prefix for ordering (e.g. +1-about.md+, +9-contact.md+) and exposed as
# navigable routes under +/pages/:slug+.
module Pages
module_function
# Lightweight value object describing a single static page discovered on
# disk. Fields are populated by {parse_page_filename} and consumed by
# route handlers and layout templates.
#
# @!attribute [r] sort_key
# @return [String] filename stem used for alphabetical ordering.
# @!attribute [r] slug
# @return [String] URL-safe identifier derived from the filename.
# @!attribute [r] title
# @return [String] human-readable nav label.
# @!attribute [r] path
# @return [String] absolute filesystem path to the Markdown source.
PageEntry = Struct.new(:sort_key, :slug, :title, :path, keyword_init: true)
# Pattern matching a safe slug segment: lowercase alphanumeric words
# separated by single hyphens. Used to validate both parsed slugs and
# incoming route parameters.
SLUG_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
# Pattern used to split a page filename into an optional numeric sort
# prefix and the slug portion.
FILENAME_PATTERN = /\A(\d+)-(.+)\z/
# Maximum number of pages loaded from disk. Prevents accidental
# directory-bomb scenarios from consuming unbounded memory.
MAX_PAGES = 50
# Kramdown options shared across all page renders.
KRAMDOWN_OPTIONS = {
input: "GFM",
hard_wrap: false,
}.freeze
# HTML tags allowed in rendered markdown output. Tags not in this list
# are stripped after rendering to prevent XSS from operator content.
ALLOWED_TAGS = Set.new(%w[
h1 h2 h3 h4 h5 h6 p a em strong b i u s del code pre br hr
ul ol li dl dt dd blockquote table thead tbody tfoot tr th td
img span div sup sub abbr mark small details summary
]).freeze
@pages_cache = nil
@pages_cache_mutex = Mutex.new
# Parse a Markdown filename into a {PageEntry} without the filesystem
# path populated.
#
# Filenames are expected to follow the pattern +<digits>-<slug>.md+ where
# the numeric prefix controls navigation order. Files without a prefix
# are accepted, using the full stem as both sort key and slug.
#
# @param basename [String] bare filename (e.g. +"9-contact.md"+).
# @return [PageEntry, nil] parsed entry or +nil+ when the filename is
# invalid or contains an unsafe slug.
def parse_page_filename(basename)
stem = basename.sub(/\.md\z/i, "")
return nil if stem.empty?
match = stem.match(FILENAME_PATTERN)
if match
slug = match[2].downcase
sort_key = stem
else
slug = stem.downcase
sort_key = stem
end
return nil unless slug.match?(SLUG_PATTERN)
title = slug.split("-").map(&:capitalize).join(" ")
PageEntry.new(sort_key: sort_key, slug: slug, title: title, path: nil)
end
# Scan the pages directory and return a sorted list of page entries.
#
# The directory is read once per call; results are not cached here (see
# {static_pages} for the cached interface). Non-+.md+ files and entries
# with invalid filenames are silently skipped.
#
# @param directory [String] absolute path to the pages directory.
# @return [Array<PageEntry>] frozen, sort-key-ordered list of pages.
def load_static_pages(directory = PotatoMesh::Config.pages_directory)
return [].freeze unless directory && File.directory?(directory)
entries = Dir.glob(File.join(directory, "*.md")).filter_map do |path|
basename = File.basename(path)
entry = parse_page_filename(basename)
next unless entry
PageEntry.new(
sort_key: entry.sort_key,
slug: entry.slug,
title: entry.title,
path: path,
)
end
entries.sort_by!(&:sort_key)
entries.uniq!(&:slug)
entries.take(MAX_PAGES).freeze
end
# Return the current set of static pages, reloading from disk when the
# cache has expired.
#
# The TTL is short in non-production environments (1 second) so that
# newly added files appear almost immediately during development.
#
# @return [Array<PageEntry>] cached page entries.
def static_pages
@pages_cache_mutex.synchronize do
if @pages_cache.nil? || Time.now > @pages_cache[:expires_at]
ttl = production_environment? ? 60 : 1
@pages_cache = {
entries: load_static_pages,
expires_at: Time.now + ttl,
}
end
@pages_cache[:entries]
end
end
# Look up a page entry by its URL slug.
#
# @param slug [String] URL slug to search for.
# @return [PageEntry, nil] matching entry or +nil+.
def find_page_by_slug(slug)
static_pages.find { |entry| entry.slug == slug }
end
# Read and render a page's Markdown source to HTML.
#
# Files exceeding {Config.max_page_file_bytes} are rejected to guard
# against accidental out-of-memory conditions. Raw HTML blocks are
# disabled at the parser level to prevent XSS.
#
# @param page_entry [PageEntry] entry whose +path+ points to the source.
# @return [String, nil] sanitised HTML string, or +nil+ when the file
# cannot be read.
def render_page_content(page_entry)
return nil unless page_entry&.path
return nil unless File.file?(page_entry.path) && File.readable?(page_entry.path)
size = File.size(page_entry.path)
return nil if size > PotatoMesh::Config.max_page_file_bytes
content = File.read(page_entry.path, encoding: "utf-8")
raw_html = Kramdown::Document.new(content, **KRAMDOWN_OPTIONS).to_html
strip_unsafe_html(raw_html)
rescue SystemCallError
nil
end
# Remove HTML tags not present in {ALLOWED_TAGS} and strip dangerous
# attributes (event handlers, javascript: URIs) from the rendered output.
# This provides a safety net against XSS when operators include raw HTML
# in their Markdown source.
#
# @param html [String] raw HTML produced by kramdown.
# @return [String] HTML with disallowed tags and attributes stripped.
def strip_unsafe_html(html)
# Delegate to the sanitize gem for robust HTML and attribute
# sanitization instead of relying on ad-hoc regular expressions.
Sanitize.fragment(
html,
elements: ALLOWED_TAGS,
attributes: {
:all => %w[id class title alt],
"a" => %w[href],
"img" => %w[src width height loading decoding],
},
protocols: {
"a" => { "href" => ["http", "https", "mailto"] },
"img" => { "src" => ["http", "https"] },
},
)
end
# Invalidate the in-memory page cache so the next call to
# {static_pages} re-scans the directory. Intended for test teardown.
#
# @return [void]
def clear_pages_cache!
@pages_cache_mutex.synchronize { @pages_cache = nil }
end
# Determine whether the application is running in a production-like
# environment.
#
# @return [Boolean] true when +RACK_ENV+ or +APP_ENV+ is +"production"+.
def production_environment?
%w[production].include?(ENV.fetch("RACK_ENV", nil)) ||
%w[production].include?(ENV.fetch("APP_ENV", nil))
end
end
end
end
@@ -159,7 +159,16 @@ module PotatoMesh
r["role"] ||= "CLIENT"
if r["role"] == "COMPANION"
derived = meshcore_companion_display_short_name(r["long_name"])
r["short_name"] = derived if derived
if derived
r["short_name"] = derived
elsif r["short_name"].nil? || r["short_name"].strip.empty?
# No derived name and no stored public-key hex — synthesise from
# the node ID (first four hex chars after the leading "!") so the
# badge is stable, unique, and consistent with how the ingestor
# builds short names from public keys.
node_id = r["node_id"].to_s.delete_prefix("!")
r["short_name"] = node_id[0, 4] unless node_id.empty?
end
end
lh = r["last_heard"]&.to_i
pt = r["position_time"]&.to_i
+25 -15
View File
@@ -19,23 +19,12 @@ module PotatoMesh
module Routes
module Root
module Helpers
# Determine the initial theme from the request cookie and persist
# sanitised values back to the client to avoid invalid states.
# Return the fixed dark theme identifier. Light mode is no longer
# supported; theme selection and cookie persistence have been removed.
#
# @return [String] normalised theme value ('dark' or 'light').
# @return [String] always 'dark'.
def resolve_initial_theme
raw_theme = request.cookies["theme"]
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
if raw_theme != theme
response.set_cookie(
"theme",
value: theme,
path: "/",
max_age: 60 * 60 * 24 * 7,
same_site: :lax,
)
end
theme
"dark"
end
# Render a dashboard-oriented ERB template within the shared layout.
@@ -70,6 +59,7 @@ module PotatoMesh
initial_theme: theme,
current_view_mode: view_mode_sym,
map_zoom: PotatoMesh::Config.map_zoom,
static_pages: PotatoMesh::App::Pages.static_pages,
}
sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
merged_locals = base_locals.merge(sanitized_locals)
@@ -191,6 +181,26 @@ module PotatoMesh
render_root_view(:federation, view_mode: :federation)
end
app.get "/pages/:slug" do
slug = params.fetch("slug", "")
halt 400, "Bad Request" unless slug.match?(PotatoMesh::App::Pages::SLUG_PATTERN)
page = PotatoMesh::App::Pages.find_page_by_slug(slug)
halt 404, "Not Found" unless page
page_html = PotatoMesh::App::Pages.render_page_content(page)
halt 500, "Internal Server Error" unless page_html
render_root_view(
:page,
view_mode: :"page_#{slug}",
extra_locals: {
page_title: page.title,
page_content_html: page_html,
},
)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
+20
View File
@@ -84,6 +84,26 @@ module PotatoMesh
value.to_s.strip != "0"
end
# Resolve the absolute path to the operator-managed static pages directory.
#
# The directory defaults to +pages/+ at the application root and can be
# overridden with the +PAGES_DIR+ environment variable.
#
# @return [String] absolute filesystem path to the pages directory.
def pages_directory
custom = fetch_string("PAGES_DIR", nil)
return File.expand_path(custom) if custom
File.join(web_root, "pages")
end
# Maximum file size in bytes accepted when reading a static page.
#
# @return [Integer] byte ceiling for markdown files.
def max_page_file_bytes
512 * 1024
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
+73
View File
@@ -0,0 +1,73 @@
# About This Mesh
Welcome to this [PotatoMesh](https://github.com/l5yth/potato-mesh) instance - a community dashboard for off-grid mesh networks. This is an example page, please modify it before deploying.
## What Is Meshtastic?
[Meshtastic](https://meshtastic.org) is an open-source project that turns
affordable LoRa radios into a decentralised, long-range communication network.
No cellular service or internet connection is required - nodes relay messages
across the mesh automatically.
## What Is Meshcore?
[Meshcore](https://meshcore.co.uk) is a firmware for LoRa radios focused on
reliable, low-power mesh networking. It provides a public channel system and
supports narrow-band presets optimised for long range in dense environments.
## Network Details
| Setting | Meshtastic | Meshcore |
| --------- | --------------- | ----------------- |
| Channel | #MediumFast | Public |
| Frequency | 869.525 MHz | 869.618 MHz |
| Bandwidth | 250 kHz | 62.5 kHz |
| SF | 8 | 8 |
| CR | 4/5 | 4/8 |
| Preset | Medium / Fast | EU/UK Narrow |
> Adjust this table to match the configuration of your local mesh.
## Contact
- **Public chat:** [#potatomesh:dod.ngo](https://matrix.to/#/#potatomesh:dod.ngo)
- **Source code:** [github.com/l5yth/potato-mesh](https://github.com/l5yth/potato-mesh)
## Custom Pages
Instance operators can add, edit, or remove pages by placing Markdown files in
the `pages/` directory (mounted as a Docker volume at `/app/pages`). Each file
becomes a new entry in the navigation bar.
### Filename Convention
```
<sort-prefix>-<slug>.md
```
- **Sort prefix** - a number that controls the order in the nav bar (e.g. `1`,
`5`, `10`). Files are sorted alphabetically by their full filename.
- **Slug** - lowercase, hyphen-separated words that become the URL path and nav
label. `contact` becomes `/pages/contact` with the label "Contact";
`privacy-policy` becomes `/pages/privacy-policy` labelled "Privacy Policy".
### Examples
| Filename | Nav Label | URL |
| --------------------- | ---------------- | ---------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `10-privacy-policy.md`| Privacy Policy | `/pages/privacy-policy`|
### Impressum / Legal Notice
Operators subject to legal disclosure requirements (e.g. the German
Telemediengesetz) can create an `impressum.md` page:
```
20-impressum.md
```
Fill it with your legally required contact details - name, address, email, phone
- and it will appear in the navigation as "Impressum".
@@ -92,14 +92,15 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
);
assert.equal(model.channels.length, 6);
// All channels have 1 message each; ties are broken alphabetically by label.
// Primary channels (index 0) come first, secondary channels (index > 0) come last.
// Within each tier, ties on messageCount are broken alphabetically by label.
assert.deepEqual(model.channels.map(channel => channel.label), [
'1',
'BerlinMesh',
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast'
'ShortFast',
'1',
'BerlinMesh',
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
@@ -512,3 +513,70 @@ test('buildChatTabModel breaks messageCount ties alphabetically', () => {
assert.equal(model.channels[0].label, 'Apple');
assert.equal(model.channels[1].label, 'Zebra');
});
test('buildChatTabModel puts primary channels (index 0) before secondary channels', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// Secondary channels with many messages
{ id: 's1', rx_time: NOW - 30, channel: 2, channel_name: 'SecondaryA' },
{ id: 's2', rx_time: NOW - 28, channel: 2, channel_name: 'SecondaryA' },
{ id: 's3', rx_time: NOW - 26, channel: 2, channel_name: 'SecondaryA' },
// Primary channel (index 0) with fewer messages
{ id: 'p1', rx_time: NOW - 20, channel: 0, channel_name: 'LongFast' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 2);
assert.equal(model.channels[0].label, 'LongFast', 'primary channel must come first regardless of activity');
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[1].label, 'SecondaryA', 'secondary channel must come second');
});
test('buildChatTabModel sorts primary channels by activity then alpha within the primary tier', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// LongFast: 1 message
{ id: 'lf1', rx_time: NOW - 30, channel: 0, channel_name: 'LongFast' },
// MediumFast: 3 messages (most active primary)
{ id: 'mf1', rx_time: NOW - 28, channel: 0, channel_name: 'MediumFast' },
{ id: 'mf2', rx_time: NOW - 26, channel: 0, channel_name: 'MediumFast' },
{ id: 'mf3', rx_time: NOW - 24, channel: 0, channel_name: 'MediumFast' },
// Public: 2 messages
{ id: 'pb1', rx_time: NOW - 22, channel: 0, channel_name: 'Public' },
{ id: 'pb2', rx_time: NOW - 20, channel: 0, channel_name: 'Public' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 3);
assert.equal(model.channels[0].label, 'MediumFast', 'most active primary first');
assert.equal(model.channels[1].label, 'Public', 'second most active primary second');
assert.equal(model.channels[2].label, 'LongFast', 'least active primary last');
});
test('buildChatTabModel sorts secondary channels by activity then alpha after all primaries', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
// Primary with 1 message
{ id: 'p1', rx_time: NOW - 50, channel: 0, channel_name: 'LongFast' },
// Secondary channels
{ id: 'b1', rx_time: NOW - 40, channel: 3, channel_name: 'Beta' },
{ id: 'a1', rx_time: NOW - 38, channel: 1, channel_name: 'Alpha' },
{ id: 'a2', rx_time: NOW - 36, channel: 1, channel_name: 'Alpha' },
{ id: 'a3', rx_time: NOW - 34, channel: 1, channel_name: 'Alpha' },
{ id: 'g1', rx_time: NOW - 32, channel: 2, channel_name: 'Gamma' },
{ id: 'g2', rx_time: NOW - 30, channel: 2, channel_name: 'Gamma' },
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 4);
assert.equal(model.channels[0].label, 'LongFast', 'primary always first');
assert.equal(model.channels[1].label, 'Alpha', 'most active secondary first');
assert.equal(model.channels[2].label, 'Gamma', 'second most active secondary second');
assert.equal(model.channels[3].label, 'Beta', 'least active secondary last');
});
@@ -809,3 +809,136 @@ test('federation page sorts by full site names before truncating visible labels'
cleanup();
}
});
test('federation table linkifies Matrix room aliases, user IDs, and bare domain paths', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'mesh.example',
name: 'Room Test',
contactLink: '@jmrplens:matrix.jmrp.io',
channel: '#mesh:server.tld',
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 3
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rowHtml = tbodyEl.childNodes[0].innerHTML;
// Matrix user ID: @jmrplens:matrix.jmrp.io → https://matrix.to/#/@jmrplens:matrix.jmrp.io
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/@jmrplens:matrix\.jmrp\.io"/);
assert.match(rowHtml, /@jmrplens:matrix\.jmrp\.io/);
// Matrix room alias in channel cell: #mesh:server.tld → https://matrix.to/#/#mesh:server.tld
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/#mesh:server\.tld"/);
assert.match(rowHtml, /#mesh:server\.tld/);
} finally {
cleanup();
}
});
test('federation table linkifies bare domain-with-path as https', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'mesh.example',
contactLink: 'discord.gg/EGdbRKQnFk',
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rowHtml = tbodyEl.childNodes[0].innerHTML;
assert.match(rowHtml, /href="https:\/\/discord\.gg\/EGdbRKQnFk"/);
assert.match(rowHtml, /discord\.gg\/EGdbRKQnFk/);
} finally {
cleanup();
}
});
test('federation table sanitises <a> tags and strips other HTML in contact field', async () => {
const { tbodyEl, cleanup } = (() => {
const e = createBasicFederationPageHarness();
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
})();
const contactWithHtml =
'<a href=https://t.me/+BpSW3no2mJgzM2I8 target=_blank>YO Telegram group</a><b> Contact:</b> YO3IBZ';
const contactViber =
'<a href="https://invite.viber.com/?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6">Viber Group</a>';
const fetchImpl = () => Promise.resolve({
ok: true,
json: async () => [
{
domain: 'a.mesh',
contactLink: contactWithHtml,
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
},
{
domain: 'b.mesh',
contactLink: contactViber,
version: '1.0.0',
latitude: 0,
longitude: 0,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
nodesCount: 1
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
const rows = tbodyEl.childNodes;
const aHtml = rows[0].innerHTML;
const bHtml = rows[1].innerHTML;
// Unquoted href extracted and normalised
assert.match(aHtml, /href="https:\/\/t\.me\/\+BpSW3no2mJgzM2I8"/);
assert.match(aHtml, /YO Telegram group/);
// <b> tag stripped, text content preserved
assert.match(aHtml, /Contact:/);
assert.doesNotMatch(aHtml, /<b>/);
// Remaining plain text present
assert.match(aHtml, /YO3IBZ/);
// Quoted href passes through correctly
assert.match(bHtml, /href="https:\/\/invite\.viber\.com\/\?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6"/);
assert.match(bHtml, /Viber Group/);
// No raw HTML from input leaks into output
assert.doesNotMatch(aHtml, /target=_blank/);
assert.doesNotMatch(aHtml, /<\/b>/);
} finally {
cleanup();
}
});
@@ -230,7 +230,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'mesh.example' }];
return [{ domain: 'mesh.example' }, { domain: 'other.mesh' }];
}
});
@@ -249,7 +249,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
defaultLabel: 'Select region ...'
});
assert.equal(select.options.length, 2);
assert.equal(select.options.length, 3);
assert.equal(select.options[1].value, 'mesh.example');
select.value = 'mesh.example';
@@ -261,6 +261,68 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
}
});
test('initializeInstanceSelector hides the selector container when fewer than 2 instances are available', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
// Simulate a parent container; mock elements lack closest() so we set
// parentElement directly so the hide logic falls back to it.
const container = env.document.createElement('div');
container.classList.add('header-federation');
select.parentElement = container;
env.document.body.appendChild(container);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'only.mesh' }];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(container.hidden, true, 'container should be hidden with fewer than 2 instances');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector keeps the selector visible when 2 or more instances are available', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const container = env.document.createElement('div');
container.classList.add('header-federation');
select.parentElement = container;
env.document.body.appendChild(container);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'alpha.mesh' }, { domain: 'beta.mesh' }];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.ok(!container.hidden, 'container should remain visible with 2 or more instances');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector updates federation navigation labels with instance count', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
@@ -44,8 +44,6 @@ export const MINIMAL_CONFIG = Object.freeze({
*/
export function setupApp() {
const env = createDomEnvironment({ includeBody: true });
// themeToggle is accessed without a null guard in initializeApp.
env.createElement('button', 'themeToggle');
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
}
@@ -65,6 +63,24 @@ export function withApp(fn) {
}
}
/**
* Spin up a DOM environment, optionally pre-register elements by id, then
* initialise the app with a custom config override. Returns the test utils,
* the environment (for DOM inspection), and a cleanup handle.
*
* @param {{ extraElements?: string[], configOverrides?: Object }} [opts]
* @returns {{ testUtils: Object, env: Object, cleanup: Function }}
*/
export function setupAppWithOptions({ extraElements = [], configOverrides = {} } = {}) {
const env = createDomEnvironment({ includeBody: true });
for (const id of extraElements) {
env.registerElement(id, env.createElement('span', id));
}
const config = { ...MINIMAL_CONFIG, ...configOverrides };
const { _testUtils } = initializeApp(config);
return { testUtils: _testUtils, env, cleanup: env.cleanup.bind(env) };
}
/**
* Extract the serialised HTML string from a DOM element returned by the test
* utils. The stub environment exposes innerHTML as a plain string; this
@@ -346,3 +346,46 @@ test('createMessageChatEntry: meshtastic message with @[Name] is NOT resolved as
assert.ok(shortNameCount <= 1, 'only the sender badge should be present, no mention badge');
});
});
// --- renderShortHtml badge padding ---
test('renderShortHtml leaves 4-char ASCII names unpadded', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('0ac7', 'CLIENT');
assert.ok(!html.includes('&nbsp;0ac7'), 'should not add leading space');
assert.ok(!html.includes('0ac7&nbsp;'), 'should not add trailing space');
});
});
test('renderShortHtml adds single space padding for short emoji names', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('\u26A1', 'CLIENT');
// Should produce " ⚡ " — one leading, one trailing space (as &nbsp;)
assert.ok(html.includes('&nbsp;\u26A1&nbsp;'), 'emoji should have one space on each side');
// Should NOT have double leading spaces
assert.ok(!html.includes('&nbsp;&nbsp;\u26A1'), 'should not double-pad emoji');
});
});
test('renderShortHtml adds single space padding for surrogate pair emoji', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('\uD83D\uDE43', 'CLIENT');
// 🙃 is a surrogate pair (length 2 in JS) but 1 grapheme
assert.ok(html.includes('&nbsp;\uD83D\uDE43&nbsp;'), 'surrogate emoji should have one space on each side');
});
});
test('renderShortHtml adds single space padding for ZWJ emoji sequence', () => {
withApp(() => {
const zwj = '\u{1F3C3}\u{200D}\u{2642}\u{FE0F}'; // 🏃‍♂️ — length 5, 1 grapheme
const html = globalThis.PotatoMesh.renderShortHtml(zwj, 'CLIENT');
assert.ok(html.includes(`&nbsp;${zwj}&nbsp;`), 'ZWJ emoji should have one space on each side');
});
});
test('renderShortHtml adds single space padding for plain 2-char name', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('ab', 'CLIENT');
assert.ok(html.includes('&nbsp;ab&nbsp;'), '2-char name should have one space on each side');
});
});
@@ -183,28 +183,10 @@ test('fetchActiveNodeStats falls back to local counts on invalid payloads', asyn
assert.equal(stats.month, 0);
});
test('formatActiveNodeStatsText emits expected dashboard string', () => {
test('formatActiveNodeStatsText emits compact day/week/month footer string', () => {
const text = formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
stats: { day: 2, week: 3, month: 4, sampled: false },
});
assert.equal(
text,
'LongFast (868MHz) — active nodes: 1/hour, 2/day, 3/week, 4/month.'
);
});
test('formatActiveNodeStatsText appends sampled marker when local fallback is used', () => {
const text = formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
});
assert.equal(
text,
'LongFast (868MHz) — active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
);
assert.equal(text, '2/day \u00b7 3/week \u00b7 4/month');
});
@@ -0,0 +1,245 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { setupApp, setupAppWithOptions } from './main-app-test-helpers.js';
const NOW = 1_700_000_000;
// ---------------------------------------------------------------------------
// updateLegendProtocolCounts
// ---------------------------------------------------------------------------
test('updateLegendProtocolCounts returns early when both count elements are null', () => {
const { testUtils, cleanup } = setupApp();
try {
// Default state: meshcoreCountEl and meshtasticCountEl are null — should not throw.
assert.doesNotThrow(() => {
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
NOW,
);
});
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts sets per-protocol counts when elements are present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, mtEl);
const nodes = [
{ last_heard: NOW - 100, protocol: 'meshcore' },
{ last_heard: NOW - 200, protocol: 'meshcore' },
{ last_heard: NOW - 300, protocol: 'meshtastic' },
{ last_heard: NOW - (8 * 86_400) }, // outside 7-day window, should not count
];
testUtils.updateLegendProtocolCounts(nodes, NOW);
assert.equal(mcEl.textContent, ' (2)', 'meshcore count should be 2');
assert.equal(mtEl.textContent, ' (1)', 'meshtastic count should be 1');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts bins unknown protocols into the meshtastic column', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, mtEl);
const nodes = [
{ last_heard: NOW - 100, protocol: 'reticulum' }, // unknown → meshtastic bucket
{ last_heard: NOW - 200, protocol: 'meshcore' },
];
testUtils.updateLegendProtocolCounts(nodes, NOW);
assert.equal(mcEl.textContent, ' (1)');
assert.equal(mtEl.textContent, ' (1)');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts works when only meshcoreCountEl is present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mcEl = { textContent: '' };
testUtils._setProtocolCountElements(mcEl, null);
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
NOW,
);
assert.equal(mcEl.textContent, ' (1)');
} finally {
cleanup();
}
});
test('updateLegendProtocolCounts works when only meshtasticCountEl is present', () => {
const { testUtils, cleanup } = setupApp();
try {
const mtEl = { textContent: '' };
testUtils._setProtocolCountElements(null, mtEl);
testUtils.updateLegendProtocolCounts(
[{ last_heard: NOW - 100, protocol: 'meshtastic' }],
NOW,
);
assert.equal(mtEl.textContent, ' (1)');
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// updateFooterStats
// ---------------------------------------------------------------------------
test('updateFooterStats is a no-op when footerActiveNodes element is absent', () => {
const { testUtils, cleanup } = setupApp();
try {
assert.doesNotThrow(() => {
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
});
} finally {
cleanup();
}
});
test('updateFooterStats populates the active-stats element when present', async () => {
const { testUtils, env, cleanup } = setupAppWithOptions({
extraElements: ['footerActiveNodes'],
});
try {
const el = env.document.getElementById('footerActiveNodes');
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
// Drain the microtask queue so the async .then callback executes.
await new Promise(resolve => setImmediate(resolve));
assert.ok(
el.textContent.includes('/day'),
`expected footerActiveNodes to contain "/day", got: ${el.textContent}`,
);
} finally {
cleanup();
}
});
test('updateFooterStats discards stale responses when a newer request is in flight', async () => {
const { testUtils, env, cleanup } = setupAppWithOptions({
extraElements: ['footerActiveNodes'],
});
try {
const el = env.document.getElementById('footerActiveNodes');
// Fire two sequential updates; only the second should be applied.
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
testUtils.updateFooterStats([{ last_heard: NOW - 200 }], NOW);
await new Promise(resolve => setImmediate(resolve));
// Either one or neither result lands; the key invariant is no error thrown
// and the element text is a valid stats string or empty.
const text = el.textContent;
assert.ok(
text === '' || text.includes('/day'),
`unexpected footerActiveNodes content: ${text}`,
);
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// restartAutoRefresh
// ---------------------------------------------------------------------------
test('restartAutoRefresh does not start a timer when refreshMs is 0', () => {
// MINIMAL_CONFIG has refreshMs: 0 — timer must not be armed.
const origSetInterval = globalThis.setInterval;
const calls = [];
globalThis.setInterval = (...args) => { calls.push(args); return origSetInterval(...args); };
try {
const { cleanup } = setupApp(); // uses refreshMs: 0
// restartAutoRefresh is called during init; no timer should have been started.
assert.equal(calls.length, 0, 'setInterval should not be called with refreshMs=0');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
}
});
test('restartAutoRefresh starts a timer when refreshMs > 0', () => {
const timers = [];
const origSetInterval = globalThis.setInterval;
const origClearInterval = globalThis.clearInterval;
globalThis.setInterval = (fn, ms) => {
const id = Symbol('timer');
timers.push({ fn, ms, id });
return id;
};
globalThis.clearInterval = () => {};
try {
const { cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
assert.equal(timers.length, 1, 'setInterval should be called once during init');
assert.equal(timers[0].ms, 30_000, 'interval should match configured refreshMs');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
}
});
test('restartAutoRefresh clears the existing timer before starting a new one', () => {
const cleared = [];
const timers = [];
const origSetInterval = globalThis.setInterval;
const origClearInterval = globalThis.clearInterval;
globalThis.setInterval = (fn, ms) => {
const id = Symbol('timer');
timers.push(id);
return id;
};
globalThis.clearInterval = id => { cleared.push(id); };
try {
const { testUtils, cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
// One timer started during init.
assert.equal(timers.length, 1);
// Calling restartAutoRefresh again must clear the first timer and start a new one.
testUtils.restartAutoRefresh();
assert.equal(cleared.length, 1, 'existing timer should be cleared');
assert.equal(cleared[0], timers[0], 'the original timer id should be cleared');
assert.equal(timers.length, 2, 'a new timer should be started');
cleanup();
} finally {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
}
});
@@ -21,6 +21,7 @@ import {
buildMessageBody,
buildMessageIndex,
normaliseMessageId,
renderLiteralWithLinks,
resolveReplyPrefix
} from '../message-replies.js';
@@ -279,3 +280,86 @@ test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal',
// @[ without closing ] does not match the pattern — treated as literal
assert.equal(body, 'ESC(hello @[unclosed)');
});
// ---------------------------------------------------------------------------
// renderLiteralWithLinks — URL detection
// ---------------------------------------------------------------------------
const e = v => `E(${v})`;
test('renderLiteralWithLinks passes plain text through escapeHtml', () => {
assert.equal(renderLiteralWithLinks('hello world', e), 'E(hello world)');
});
test('renderLiteralWithLinks wraps http:// URL in an anchor element', () => {
const result = renderLiteralWithLinks('check http://example.com out', e);
assert.equal(result, 'E(check )<a href="E(http://example.com)" target="_blank" rel="noopener noreferrer">E(http://example.com)</a>E( out)');
});
test('renderLiteralWithLinks wraps https:// URL in an anchor element', () => {
const result = renderLiteralWithLinks('see https://example.com/path?q=1', e);
assert.ok(result.includes('<a href='), 'should produce an anchor');
assert.ok(result.includes('target="_blank"'), 'should open in new tab');
assert.ok(result.includes('rel="noopener noreferrer"'), 'should include noopener rel');
});
test('renderLiteralWithLinks strips trailing period from URL', () => {
const result = renderLiteralWithLinks('visit https://example.com.', e);
assert.ok(result.includes('href="E(https://example.com)"'), 'period should not be in href');
assert.ok(result.includes('>E(https://example.com)<'), 'period should not be in link text');
assert.ok(result.endsWith('E(.)'), 'trailing period should appear as escaped text after the link');
});
test('renderLiteralWithLinks strips trailing comma from URL', () => {
const result = renderLiteralWithLinks('go to https://example.com, then stop', e);
assert.ok(result.includes('href="E(https://example.com)"'), 'comma must not be in href');
});
test('renderLiteralWithLinks handles URL at the start of text', () => {
const result = renderLiteralWithLinks('https://example.com is great', e);
assert.ok(result.startsWith('<a href='), 'anchor should be at start');
assert.ok(result.endsWith('E( is great)'), 'text after URL should be escaped');
});
test('renderLiteralWithLinks handles URL at the end of text', () => {
const result = renderLiteralWithLinks('see https://example.com', e);
assert.ok(result.startsWith('E(see )'), 'text before URL should be escaped');
assert.ok(result.includes('<a href='), 'URL should be linked');
});
test('renderLiteralWithLinks handles multiple URLs in text', () => {
const result = renderLiteralWithLinks('a https://foo.com b https://bar.com c', e);
const matches = result.match(/<a href=/g) || [];
assert.equal(matches.length, 2, 'should produce two anchors');
});
test('renderLiteralWithLinks does not linkify non-http schemes', () => {
const result = renderLiteralWithLinks('ftp://example.com', e);
assert.ok(!result.includes('<a href='), 'ftp:// should not be linkified');
assert.equal(result, 'E(ftp://example.com)');
});
test('renderLiteralWithLinks returns empty string for empty input', () => {
assert.equal(renderLiteralWithLinks('', e), '');
});
test('buildMessageBody linkifies URLs in message text without renderMentionHtml', () => {
const body = buildMessageBody({
message: { text: 'visit https://example.com now' },
escapeHtml: e,
renderEmojiHtml: v => `EMOJI(${v})`,
});
assert.ok(body.includes('<a href='), 'URL should be linkified');
assert.ok(body.includes('target="_blank"'), 'should open in new tab');
});
test('buildMessageBody linkifies URLs alongside @[Name] mentions', () => {
const body = buildMessageBody({
message: { text: '@[Alice] see https://example.com' },
escapeHtml: e,
renderEmojiHtml: v => `EMOJI(${v})`,
renderMentionHtml: name => `BADGE(${name})`,
});
assert.ok(body.startsWith('BADGE(Alice)'), 'mention should be rendered as badge');
assert.ok(body.includes('<a href='), 'URL should be linkified');
});
@@ -237,29 +237,16 @@ test('fetchActiveNodeStats concurrent calls share a single in-flight request', a
// formatActiveNodeStatsText
// ---------------------------------------------------------------------------
test('formatActiveNodeStatsText emits expected dashboard sentence', () => {
test('formatActiveNodeStatsText emits compact day/week/month string', () => {
assert.equal(
formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
stats: { day: 2, week: 3, month: 4, sampled: false },
}),
'LongFast (868MHz) \u2014 active nodes: 1/hour, 2/day, 3/week, 4/month.'
);
});
test('formatActiveNodeStatsText appends sampled marker', () => {
assert.equal(
formatActiveNodeStatsText({
channel: 'LongFast',
frequency: '868MHz',
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
}),
'LongFast (868MHz) \u2014 active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
'2/day \u00b7 3/week \u00b7 4/month'
);
});
test('formatActiveNodeStatsText handles missing or null stats gracefully', () => {
const text = formatActiveNodeStatsText({ channel: 'X', frequency: 'Y', stats: null });
assert.ok(text.includes('0/hour'), 'defaults to zero counts for null stats');
const text = formatActiveNodeStatsText({ stats: null });
assert.equal(text, '0/day \u00b7 0/week \u00b7 0/month', 'defaults to zero counts for null stats');
});
@@ -57,8 +57,6 @@ function executeInDom(source, url, env) {
test('theme and background modules behave correctly across scenarios', async t => {
const env = createDomEnvironment({ readyState: 'complete', cookie: '' });
try {
const toggle = env.createElement('button', 'themeToggle');
env.registerElement('themeToggle', toggle);
let filterInvocations = 0;
env.window.applyFiltersToAllTiles = () => {
filterInvocations += 1;
@@ -72,52 +70,27 @@ test('theme and background modules behave correctly across scenarios', async t =
const backgroundHelpers = env.window.__potatoBackground;
const backgroundHooks = backgroundHelpers.__testHooks;
await t.test('initialises with a dark theme and persists cookies', () => {
await t.test('initialises with a dark theme', () => {
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
assert.equal(env.document.body.classList.contains('dark'), true);
assert.equal(toggle.textContent, '☀️');
themeHelpers.persistTheme('light');
themeHelpers.setCookie('bare', '1');
themeHooks.exerciseSetCookieGuard();
themeHelpers.setCookie('flag', 'true', { Secure: true });
const cookieString = env.getCookieString();
assert.equal(themeHelpers.getCookie('flag'), 'true');
assert.equal(themeHelpers.getCookie('missing'), null);
assert.match(cookieString, /theme=light/);
assert.match(cookieString, /; path=\//);
assert.match(cookieString, /; SameSite=Lax/);
assert.match(cookieString, /; Secure/);
});
await t.test('serializeCookieOptions covers boolean and string attributes', () => {
const withAttributes = themeHooks.serializeCookieOptions({ Secure: true, HttpOnly: '1' });
assert.equal(withAttributes.includes('; Secure'), true);
assert.equal(withAttributes.includes('; HttpOnly=1'), true);
const secureOnly = themeHooks.serializeCookieOptions({ Secure: true });
assert.equal(secureOnly.trim(), '; Secure');
assert.equal(themeHooks.formatCookieOption(['HttpOnly', '1']), '; HttpOnly=1');
assert.equal(themeHooks.formatCookieOption(['Secure', true]), '; Secure');
assert.equal(themeHooks.serializeCookieOptions({}), '');
assert.equal(themeHooks.serializeCookieOptions(), '');
});
await t.test('re-bootstrap handles DOMContentLoaded flow and filter hooks', () => {
env.document.readyState = 'loading';
filterInvocations = 0;
env.setCookieString('theme=light');
themeHooks.bootstrap();
env.triggerDOMContentLoaded();
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'light');
assert.equal(env.document.body.classList.contains('dark'), false);
assert.equal(toggle.textContent, '🌙');
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
assert.equal(env.document.body.classList.contains('dark'), true);
assert.equal(filterInvocations, 1);
env.document.removeEventListener('DOMContentLoaded', themeHooks.handleReady);
});
await t.test('handleReady tolerates missing toggle button', () => {
env.registerElement('themeToggle', null);
await t.test('handleReady calls applyFiltersToAllTiles', () => {
filterInvocations = 0;
env.document.readyState = 'complete';
themeHooks.handleReady();
env.registerElement('themeToggle', toggle);
assert.equal(filterInvocations, 1);
});
await t.test('applyTheme copes with absent DOM nodes', () => {
@@ -125,10 +98,10 @@ test('theme and background modules behave correctly across scenarios', async t =
const originalRoot = env.document.documentElement;
env.document.body = null;
env.document.documentElement = null;
assert.equal(themeHooks.applyTheme('dark'), true);
// Should not throw even when DOM nodes are absent
assert.doesNotThrow(() => themeHooks.applyTheme());
env.document.body = originalBody;
env.document.documentElement = originalRoot;
assert.equal(themeHooks.applyTheme('light'), false);
});
await t.test('background bootstrap waits for DOM readiness', () => {
@@ -161,12 +134,12 @@ test('theme and background modules behave correctly across scenarios', async t =
env.document.body = originalBody;
});
await t.test('theme changes trigger background updates', () => {
env.document.body.classList.remove('dark');
themeHooks.setTheme('light');
await t.test('themechange event triggers background update', () => {
env.document.body.classList.add('dark');
backgroundHooks.init();
env.dispatchWindowEvent('themechange');
assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee');
// Background should reflect dark mode
assert.ok(env.document.documentElement.style.backgroundColor !== '');
});
env.window.removeEventListener('themechange', backgroundHelpers.applyBackground);
+11 -4
View File
@@ -311,10 +311,17 @@ export function buildChatTabModel({
channel.entries.sort((a, b) => a.ts - b.ts);
channel.messageCount = channel.entries.length;
}
// Sort channels by activity (most messages first), then alphabetically on ties.
const channels = Array.from(channelBuckets.values()).sort((a, b) =>
b.messageCount - a.messageCount || a.label.localeCompare(b.label)
);
// Sort channels into two tiers:
// 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
// ordered by activity desc so the most-active protocol leads within the tier.
// 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
// Within each tier, ties on messageCount are broken alphabetically by label.
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
const aTier = a.index === 0 ? 0 : 1;
const bTier = b.index === 0 ? 0 : 1;
if (aTier !== bTier) return aTier - bTier;
return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
});
return { logEntries, channels };
}
+119 -22
View File
@@ -113,38 +113,135 @@ function colorForNodeCount(count) {
}
/**
* Render arbitrary contact text while hyperlinking recognised URL-like segments.
* Matches recognised link-like segments in plain text:
* - Absolute URLs (https?://, mailto:, matrix:)
* - Matrix room aliases (#room:domain.tld)
* - Matrix user IDs (@user:domain.tld)
* - Bare domain-with-path (discord.gg/..., t.me/...)
*
* @param {*} contact Raw contact value from the API.
* @returns {string} HTML markup safe for insertion.
* Character classes use possessive-style atomic groupings (no overlap) so the
* regex engine cannot backtrack into super-linear runtime.
*/
function renderContactHtml(contact) {
if (typeof contact !== 'string') return '';
const trimmed = contact.trim();
if (!trimmed) return '';
const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi;
const CONTACT_LINK_PATTERN =
/(https?:\/\/\S+|mailto:\S+|matrix:\S+|[@#][a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\/\S+)/gi;
/**
* Regex matching `<a>` elements (including malformed unquoted attributes) and
* any other HTML tags so they can be handled separately from plain text.
*
* The `<a>` branch uses `[^<]*` (no nesting) instead of `[\s\S]*?` to avoid
* super-linear backtracking when nested or malformed tags are present.
*/
const HTML_SEGMENT_PATTERN = /(<a\b[^>]*>[^<]*<\/a\s*>|<[^>]+>)/gi;
/** Extracts the href value from an `<a>` opening tag, quoted or unquoted. */
const HREF_ATTR_PATTERN = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/i;
/** Protocols allowed in whitelisted `<a>` hrefs. */
const SAFE_HREF_RE = /^(?:https?:\/\/|matrix:|mailto:)/i;
/**
* Resolve a raw matched token to an href string.
*
* @param {string} raw Matched token from CONTACT_LINK_PATTERN.
* @returns {string} Absolute href suitable for use in an anchor element.
*/
function resolveHref(raw) {
if (raw.startsWith('#') || raw.startsWith('@')) {
// Matrix room alias or user ID → matrix.to permalink
return `https://matrix.to/#/${raw}`;
}
if (/^[a-zA-Z0-9-]+\./.test(raw) && !raw.includes('://')) {
// Bare domain-with-path (e.g. discord.gg/…) — prepend https://
return `https://${raw}`;
}
return raw;
}
/**
* Linkify URL-like tokens in a plain-text (already HTML-free) segment.
*
* @param {string} text Plain text with no HTML tags.
* @returns {string} HTML-safe string with recognised links wrapped in anchors.
*/
function renderPlainSegment(text) {
const parts = [];
let lastIndex = 0;
const pattern = new RegExp(CONTACT_LINK_PATTERN.source, 'gi');
let match;
while ((match = pattern.exec(text)) !== null) {
const before = text.slice(lastIndex, match.index);
if (before) parts.push(escapeHtml(before));
const raw = match[0];
const href = resolveHref(raw);
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(raw)}</a>`);
lastIndex = match.index + raw.length;
}
const trailing = text.slice(lastIndex);
if (trailing) parts.push(escapeHtml(trailing));
return parts.join('');
}
/**
* Render arbitrary contact or channel text as safe HTML.
*
* - Existing `<a>` tags are sanitised: href is validated against an allowlist
* of safe protocols (https, http, matrix:, mailto:), link text is escaped,
* and `target="_blank" rel="noopener noreferrer"` is always applied.
* - Other HTML tags (e.g. `<b>`) are stripped; their text content is kept.
* - Plain-text segments are scanned for URLs, Matrix aliases/user-IDs, and
* bare domain-with-path references (e.g. discord.gg/) and linkified.
* - Line breaks are converted to `<br>`.
*
* @param {*} text Raw value from the API (may contain HTML).
* @returns {string} HTML markup safe for insertion.
*/
function renderContactHtml(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (!trimmed) return '';
const parts = [];
let lastIndex = 0;
const segPattern = new RegExp(HTML_SEGMENT_PATTERN.source, 'gi');
let match;
while ((match = urlPattern.exec(trimmed)) !== null) {
const textBefore = trimmed.slice(lastIndex, match.index);
if (textBefore) {
parts.push(escapeHtml(textBefore));
while ((match = segPattern.exec(trimmed)) !== null) {
// Linkify plain text before this HTML segment
const before = trimmed.slice(lastIndex, match.index);
if (before) parts.push(renderPlainSegment(before));
const tag = match[0];
if (/^<a\b/i.test(tag)) {
// Whitelisted <a> tag — extract href, validate, re-render safely
const hrefMatch = HREF_ATTR_PATTERN.exec(tag);
const href = hrefMatch ? (hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? '') : '';
// Strip HTML tags to derive plain link text; content is still escaped below.
// The replace runs in a loop to handle residual tags left after the first
// pass — this is safe because a single-pass replace of `<…>` can leave
// behind a reconstructed tag when the input contains e.g. `<<script>`.
let linkText = tag;
let prev;
do { prev = linkText; linkText = linkText.replace(/<[^>]*>/g, ''); } while (linkText !== prev);
linkText = linkText.trim();
if (SAFE_HREF_RE.test(href)) {
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(linkText || href)}</a>`);
} else {
// Unsafe or missing href — render link text only
parts.push(escapeHtml(linkText));
}
}
const url = match[0];
const safeUrl = escapeHtml(url);
parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
lastIndex = match.index + url.length;
// Other tags (<b>, </b>, etc.) are stripped; their text content falls
// through as plain text in subsequent iterations.
lastIndex = match.index + tag.length;
}
// Linkify any remaining plain text after the last HTML segment
const trailing = trimmed.slice(lastIndex);
if (trailing) {
parts.push(escapeHtml(trailing));
}
if (trailing) parts.push(renderPlainSegment(trailing));
const html = parts.join('');
return html.replace(/\r?\n/g, '<br>');
return parts.join('').replace(/\r?\n/g, '<br>');
}
/**
@@ -387,7 +484,7 @@ export async function initializeFederationPage(options = {}) {
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
<td class="instances-col instances-col--contact">${contactHtml || '<em>—</em>'}</td>
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
<td class="instances-col instances-col--channel">${renderContactHtml(instance.channel) || ''}</td>
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
@@ -205,6 +205,18 @@ export async function initializeInstanceSelector(options) {
const visibleEntries = filterDisplayableFederationInstances(payload);
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
// Hide the selector when fewer than two instances are available — a single
// entry (only the current instance) offers no meaningful navigation choice.
if (visibleEntries.length < 2) {
const selectorContainer = (typeof selectElement.closest === 'function'
? selectElement.closest('.header-federation')
: null) || selectElement.parentElement;
if (selectorContainer) {
selectorContainer.hidden = true;
}
return;
}
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = visibleEntries
+113 -250
View File
@@ -131,30 +131,20 @@ import {
*/
export function initializeApp(config) {
const statusEl = document.getElementById('status');
const fitBoundsEl = document.getElementById('fitBounds');
const autoRefreshEl = document.getElementById('autoRefresh');
const footerActiveNodes = document.getElementById('footerActiveNodes');
const refreshBtn = document.getElementById('refreshBtn');
const filterInput = document.getElementById('filterInput');
const filterClearButton = document.getElementById('filterClear');
const themeToggle = document.getElementById('themeToggle');
const infoBtn = document.getElementById('infoBtn');
const infoOverlay = document.getElementById('infoOverlay');
const infoClose = document.getElementById('infoClose');
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate');
const overlayStack = createShortInfoOverlayStack({ document, window, template: shortInfoTemplate });
const titleEl = document.querySelector('title');
const headerEl = document.querySelector('h1');
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
const chatEl = document.getElementById('chat');
const refreshInfo = document.getElementById('refreshInfo');
const instanceSelect = document.getElementById('instanceSelect');
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
const infoOverlayHome = infoOverlay
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
: null;
const bodyClassList = document.body ? document.body.classList : null;
const isPrivateMode = document.body && document.body.dataset
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
@@ -245,13 +235,6 @@ export function initializeApp(config) {
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
if (refreshInfo) {
if (isDashboardView) {
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
} else {
refreshInfo.textContent = '';
}
}
if (instanceSelectorEnabled && instanceSelect) {
void initializeInstanceSelector({
@@ -265,7 +248,7 @@ export function initializeApp(config) {
/** @type {ReturnType<typeof setTimeout>|null} */
let refreshTimer = null;
let refreshInfoRequestId = 0;
let activeStatsRequestId = 0;
/**
* Close any open short-info overlays that do not contain the provided anchor.
@@ -463,24 +446,18 @@ export function initializeApp(config) {
*/
function restartAutoRefresh() {
// Tear down any existing timer so the interval never double-fires when
// the user toggles auto-refresh or the config is re-applied.
// the config is re-applied.
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
// Only arm the timer when the auto-refresh checkbox is checked; a
// disabled checkbox (e.g. during an active fetch) keeps it off.
if (autoRefreshEl && autoRefreshEl.checked) {
// Only arm the timer when a positive interval is configured; a zero or
// negative value means auto-refresh is intentionally disabled.
if (REFRESH_MS > 0) {
refreshTimer = setInterval(refresh, REFRESH_MS);
}
}
if (fitBoundsEl && mapZoomOverride !== null) {
fitBoundsEl.checked = false;
fitBoundsEl.disabled = true;
fitBoundsEl.setAttribute('aria-disabled', 'true');
}
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
const mapContainer = document.getElementById('map');
@@ -524,7 +501,7 @@ export function initializeApp(config) {
];
const autoFitController = createMapAutoFitController({
toggleEl: fitBoundsEl,
toggleEl: null,
windowObject: typeof window !== 'undefined' ? window : undefined,
defaultPaddingPx: AUTO_FIT_PADDING_PX
});
@@ -542,7 +519,6 @@ export function initializeApp(config) {
const centerResetHandler = createMapCenterResetHandler({
getMap: () => map,
autoFitController,
fitBoundsEl,
fitMapToBounds,
mapCenterCoords: MAP_CENTER_COORDS,
mapZoomOverride,
@@ -739,62 +715,6 @@ export function initializeApp(config) {
});
});
/**
* Append the informational modal overlay to the fullscreen container when active.
*
* @returns {void}
*/
function attachInfoOverlayToFullscreenHost() {
if (!infoOverlay || !fullscreenContainer) return;
if (infoOverlay.parentNode !== fullscreenContainer) {
fullscreenContainer.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.add('info-overlay--fullscreen');
}
}
/**
* Restore the informational overlay to its original DOM position.
*
* @returns {void}
*/
function restoreInfoOverlayToHome() {
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
if (infoOverlay.parentNode === infoOverlayHome.parent) {
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
return;
}
if (
infoOverlayHome.nextSibling &&
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
typeof infoOverlayHome.parent.insertBefore === 'function'
) {
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
infoOverlayHome.parent.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
}
/**
* Ensure the informational overlay participates in the active fullscreen subtree.
*
* @returns {void}
*/
function syncInfoOverlayHost() {
if (!infoOverlay) return;
if (isMapInFullscreen()) {
attachInfoOverlayToFullscreenHost();
} else {
restoreInfoOverlayToHome();
}
}
/**
* Respond to fullscreen change events originating from the browser.
*
@@ -825,7 +745,6 @@ export function initializeApp(config) {
mapContainer.style.minHeight = '';
}
}
syncInfoOverlayHost();
updateFullscreenToggleState();
refreshMapSize();
}
@@ -853,8 +772,6 @@ export function initializeApp(config) {
});
}
syncInfoOverlayHost();
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
const activeRoleFilters = new Set();
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
@@ -1395,6 +1312,8 @@ export function initializeApp(config) {
let legendContainer = null;
let legendToggleControl = null;
let meshcoreCountEl = null;
let meshtasticCountEl = null;
let legendToggleButton = null;
let legendVisible = true;
@@ -1479,7 +1398,9 @@ export function initializeApp(config) {
if (!neighborLinesToggleButton) return;
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
neighborLinesToggleButton.textContent = label;
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
// aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are
// currently hidden). When lines are visible (default), the button is unpressed.
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true');
neighborLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1511,7 +1432,8 @@ export function initializeApp(config) {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
traceLinesToggleButton.textContent = label;
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
// aria-pressed reflects whether the user has *activated* the toggle (lines hidden).
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true');
traceLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1629,14 +1551,30 @@ export function initializeApp(config) {
}
if (map && hasLeaflet) {
const legend = L.control({ position: 'bottomright' });
// Single combined control: [toggle button | legend panel] in a flex row.
// The toggle sits to the left so it remains accessible when the legend is collapsed.
const legendControl = L.control({ position: 'bottomright' });
/**
* Leaflet control factory that renders the legend UI.
* Leaflet control factory that renders the toggle button and legend panel
* as a single side-by-side control.
*
* @returns {HTMLElement} Legend DOM element.
* @returns {HTMLElement} Wrapper element containing both children.
*/
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legend');
legendControl.onAdd = function () {
const wrapper = L.DomUtil.create('div', 'legend-outer');
// --- Toggle button (left) ---
const button = L.DomUtil.create('button', 'legend-toggle-button', wrapper);
button.type = 'button';
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-controls', 'mapLegend');
button.addEventListener('click', legendClickHandler(() => {
setLegendVisibility(!legendVisible);
}));
legendToggleButton = button;
// --- Legend panel (right) ---
const div = L.DomUtil.create('div', 'legend', wrapper);
div.id = 'mapLegend';
div.setAttribute('role', 'region');
div.setAttribute('aria-label', 'Map legend');
@@ -1648,13 +1586,16 @@ export function initializeApp(config) {
const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
// --- MeshCore column (left) ---
const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
// --- MeshCore column (left, bottom-aligned) ---
const meshcoreCol = L.DomUtil.create('div', 'legend-column legend-column--bottom', itemsContainer);
const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
meshcoreColHeader.appendChild(buildMeshcoreIconImg());
const meshcoreColTitle = document.createElement('span');
meshcoreColTitle.textContent = 'MeshCore';
meshcoreColHeader.appendChild(meshcoreColTitle);
meshcoreCountEl = document.createElement('span');
meshcoreCountEl.className = 'legend-protocol-count';
meshcoreColHeader.appendChild(meshcoreCountEl);
// --- Meshtastic column (right) ---
const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
@@ -1663,18 +1604,17 @@ export function initializeApp(config) {
const meshtasticColTitle = document.createElement('span');
meshtasticColTitle.textContent = 'Meshtastic';
meshtasticColHeader.appendChild(meshtasticColTitle);
meshtasticCountEl = document.createElement('span');
meshtasticCountEl.className = 'legend-protocol-count';
meshtasticColHeader.appendChild(meshtasticCountEl);
legendRoleButtons.clear();
buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
// --- Protocol hide toggles — one per column footer ---
// --- MeshCore column footer: protocol hide toggle ---
legendProtocolButtons.clear();
const protocolColDefs = [
{ protocol: 'meshcore', col: meshcoreCol },
{ protocol: 'meshtastic', col: meshtasticCol },
];
for (const { protocol, col } of protocolColDefs) {
const buildProtocolToggle = (protocol, col) => {
const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col);
btn.type = 'button';
@@ -1690,9 +1630,10 @@ export function initializeApp(config) {
applyFilter();
}));
legendProtocolButtons.set(protocol, btn);
}
};
buildProtocolToggle('meshcore', meshcoreCol);
// --- Line toggles in the Meshtastic column ---
// --- Meshtastic column: line toggles then protocol hide toggle at bottom ---
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
neighborLinesToggleButton.type = 'button';
neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
@@ -1707,12 +1648,15 @@ export function initializeApp(config) {
}));
updateTraceLinesToggleState();
// Hide Meshtastic toggle at the very bottom of the Meshtastic column.
buildProtocolToggle('meshtastic', meshtasticCol);
updateLegendRoleFiltersUI();
// --- Clear filters — full-width below the two columns ---
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
const filterToggle = L.DomUtil.create('div', 'legend-toggle', div);
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', filterToggle);
resetButton.type = 'button';
resetButton.textContent = 'Clear filters';
resetButton.addEventListener('click', legendClickHandler(() => {
@@ -1722,38 +1666,12 @@ export function initializeApp(config) {
applyFilter();
}));
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
legend.addTo(map);
legendContainer = legend.getContainer();
legendToggleControl = L.control({ position: 'bottomright' });
/**
* Leaflet control factory for the legend visibility toggle.
*
* @returns {HTMLElement} Toggle button element.
*/
legendToggleControl.onAdd = function () {
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
button.type = 'button';
button.textContent = 'Hide legend (filters)';
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Hide map legend');
button.setAttribute('aria-controls', 'mapLegend');
button.addEventListener('click', legendClickHandler(() => {
setLegendVisibility(!legendVisible);
}));
legendToggleButton = button;
updateLegendToggleState();
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
return container;
L.DomEvent.disableClickPropagation(wrapper);
L.DomEvent.disableScrollPropagation(wrapper);
return wrapper;
};
legendToggleControl.addTo(map);
legendControl.addTo(map);
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
const initialLegendVisible = resolveLegendVisibility({
@@ -1770,72 +1688,6 @@ export function initializeApp(config) {
setLegendVisibility(false);
}
themeToggle.addEventListener('click', () => {
const dark = document.body.classList.toggle('dark');
const themeValue = dark ? 'dark' : 'light';
document.body.setAttribute('data-theme', themeValue);
if (document.documentElement) {
document.documentElement.setAttribute('data-theme', themeValue);
}
themeToggle.textContent = dark ? '☀️' : '🌙';
if (window.__themeCookie) {
if (typeof window.__themeCookie.persistTheme === 'function') {
window.__themeCookie.persistTheme(themeValue);
} else if (typeof window.__themeCookie.setCookie === 'function') {
window.__themeCookie.setCookie('theme', themeValue);
}
}
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
});
let lastFocusBeforeInfo = null;
/**
* Display the modal overlay containing site information.
*
* @returns {void}
*/
function openInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
syncInfoOverlayHost();
lastFocusBeforeInfo = document.activeElement;
infoOverlay.hidden = false;
document.body.style.setProperty('overflow', 'hidden');
infoDialog.focus();
}
/**
* Hide the site information overlay and restore focus.
*
* @returns {void}
*/
function closeInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
infoOverlay.hidden = true;
document.body.style.removeProperty('overflow');
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
if (target && typeof target.focus === 'function') {
target.focus();
}
lastFocusBeforeInfo = null;
}
if (infoBtn && infoOverlay && infoClose) {
infoBtn.addEventListener('click', openInfoOverlay);
infoClose.addEventListener('click', closeInfoOverlay);
infoOverlay.addEventListener('click', event => {
if (event.target === infoOverlay) {
closeInfoOverlay();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !infoOverlay.hidden) {
closeInfoOverlay();
}
});
}
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
document,
privateMode: isPrivateMode,
@@ -2001,22 +1853,22 @@ export function initializeApp(config) {
infoAttr = attrParts.join('');
}
if (!short) {
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>&nbsp;?&nbsp;</span>`;
}
// Centre the label within a 4-column badge. padStart alone only adds
// leading spaces, producing " C" for a 1-char name with no trailing
// space. Instead distribute padding evenly: 1-char → " C ", 2-char →
// " AB ", 3-char → " ABC", 4-char → unchanged. Names already at 4+
// chars are left as-is (meshtastic always stores exactly 4; the Ruby
// COMPANION override also produces exactly 4).
// Pad the label for the badge. For plain-ASCII names that are already
// 4 characters (meshtastic always stores exactly 4) no padding is added.
// Shorter names or names containing emoji/non-ASCII get a single space
// on each side — grapheme width varies too much for character-count
// centering to work reliably.
const raw = String(short);
const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter
? [...new Intl.Segmenter().segment(raw)].length
: raw.length;
let centred;
if (raw.length >= 4) {
if (graphemeCount >= 4) {
centred = raw;
} else {
const leading = Math.ceil((4 - raw.length) / 2);
const trailing = 4 - raw.length - leading;
centred = ' '.repeat(leading) + raw + ' '.repeat(trailing);
centred = ` ${raw} `;
}
const padded = escapeHtml(centred).replace(/ /g, '&nbsp;');
const protocol = nodeData?.protocol ?? null;
@@ -4435,10 +4287,6 @@ export function initializeApp(config) {
},
});
}
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
const bounds = computeBoundsForPoints(pts, { ...autoFitBoundsConfig });
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
}
overlayStack.cleanupOrphans();
}
@@ -4521,8 +4369,11 @@ export function initializeApp(config) {
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
updateCount(sortedNodes, nowSec);
updateRefreshInfo(sortedNodes, nowSec);
// Title and legend counts are intentionally global — they reflect the whole
// network, not just the nodes visible under the current filter.
updateTitleCount(allNodes, nowSec);
updateLegendProtocolCounts(allNodes, nowSec);
updateFooterStats(sortedNodes, nowSec);
updateSortIndicators();
// Pass the raw filterQuery (not the normalised form) so the chat log can
// highlight matching substrings in their original case.
@@ -4658,8 +4509,8 @@ export function initializeApp(config) {
}
}
// Kick off the first data load immediately; interval-based auto-refresh
// begins only if the checkbox is already checked on page load.
// Kick off the first data load immediately then start the silent background
// auto-refresh timer.
refresh();
restartAutoRefresh();
@@ -4668,27 +4519,16 @@ export function initializeApp(config) {
refreshBtn.addEventListener('click', refresh);
}
if (autoRefreshEl) {
// When the user enables auto-refresh mid-session, trigger an immediate
// fetch so the UI does not sit stale until the next interval fires.
autoRefreshEl.addEventListener('change', () => {
restartAutoRefresh();
if (autoRefreshEl.checked) {
refresh();
}
});
}
/**
* Update the count badge showing how many nodes are displayed.
* Update the page/tab title with the total active-node count for the past 7 days.
*
* @param {Array<Object>} nodes Node payloads.
* @param {Array<Object>} nodes All node payloads (unfiltered counts are global).
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateCount(nodes, nowSec) {
const dayAgoSec = nowSec - 86400;
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
function updateTitleCount(nodes, nowSec) {
const weekAgoSec = nowSec - 7 * 86_400;
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= weekAgoSec).length;
const text = `${baseTitle} (${count})`;
titleEl.textContent = text;
if (headerTitleTextEl) {
@@ -4699,26 +4539,40 @@ export function initializeApp(config) {
}
/**
* Update the status message describing the currently rendered data.
* Update legend column headers with per-protocol active node counts (7 days).
*
* @param {Array<Object>} nodes All node payloads (unfiltered).
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateLegendProtocolCounts(nodes, nowSec) {
if (!meshcoreCountEl && !meshtasticCountEl) return;
const weekAgoSec = nowSec - 7 * 86_400;
const recentNodes = nodes.filter(n => Number.isFinite(Number(n.last_heard)) && Number(n.last_heard) >= weekAgoSec);
const meshcoreCount = recentNodes.filter(n => n.protocol === 'meshcore').length;
// Treat any non-meshcore node as Meshtastic until additional protocols are supported.
const meshtasticCount = recentNodes.filter(n => n.protocol !== 'meshcore').length;
if (meshcoreCountEl) meshcoreCountEl.textContent = ` (${meshcoreCount})`;
if (meshtasticCountEl) meshtasticCountEl.textContent = ` (${meshtasticCount})`;
}
/**
* Update the footer active-node stats element with day/week/month counts.
*
* @param {Array<Object>} nodes Node payloads.
* @param {number} nowSec Reference timestamp.
* @returns {void}
*/
function updateRefreshInfo(nodes, nowSec) {
if (!refreshInfo || !isDashboardView) {
function updateFooterStats(nodes, nowSec) {
if (!footerActiveNodes) {
return;
}
const requestId = ++refreshInfoRequestId;
const requestId = ++activeStatsRequestId;
void fetchActiveNodeStats({ nodes, nowSeconds: nowSec }).then(stats => {
if (requestId !== refreshInfoRequestId) {
if (requestId !== activeStatsRequestId) {
return;
}
refreshInfo.textContent = formatActiveNodeStatsText({
channel: config.channel,
frequency: config.frequency,
stats
});
footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats });
});
}
@@ -4750,6 +4604,15 @@ export function initializeApp(config) {
hiddenProtocols,
legendRoleButtons,
legendProtocolButtons,
updateTitleCount,
updateLegendProtocolCounts,
updateFooterStats,
restartAutoRefresh,
/** Inject mock count span elements for legend protocol count tests. */
_setProtocolCountElements(mc, mt) {
meshcoreCountEl = mc;
meshtasticCountEl = mt;
},
},
};
}
+53 -8
View File
@@ -339,12 +339,55 @@ function resolveMessageTextSegment(message, isReaction) {
}
/**
* Render a text segment, replacing ``@[Name]`` mention patterns with the
* output of ``renderMentionHtml`` when provided. Segments between mentions
* are passed through ``escapeHtml`` to prevent XSS.
* Regex with a single capturing group that matches http:// and https:// URLs.
* Used by {@link renderLiteralWithLinks} to split text into URL and non-URL
* segments while preserving the matched URL in the resulting array.
* @type {RegExp}
*/
const URL_SPLIT_PATTERN = /(https?:\/\/[^\s<>"'[\]]{1,2048})/;
/**
* Strip trailing punctuation characters that are typically sentence
* delimiters rather than part of a URL (e.g. a period at end of sentence).
*
* When ``renderMentionHtml`` is ``null`` the function behaves identically to
* ``escapeHtml(text)``, preserving backward compatibility.
* @param {string} url Raw URL candidate.
* @returns {string} URL with trailing punctuation trimmed.
*/
function trimUrlTrailingPunctuation(url) {
return url.replace(/[.,;!?)]+$/, '');
}
/**
* Render a single raw text segment, converting any ``http://`` or
* ``https://`` URLs into ``<a>`` elements that open in a new tab.
* Non-URL text is passed through ``escapeHtml`` unchanged.
*
* @param {string} text Raw (unescaped) literal text.
* @param {Function} escapeHtml HTML-escape function.
* @returns {string} Safe HTML with URLs wrapped in anchor elements.
*/
export function renderLiteralWithLinks(text, escapeHtml) {
// split() with a capturing group interleaves plain text (even indices)
// and matched URLs (odd indices): ["before", "https://x", " after", ...]
const parts = text.split(URL_SPLIT_PATTERN);
return parts.map((part, i) => {
if (i % 2 === 0) {
return part ? escapeHtml(part) : '';
}
// URL segment — strip trailing punctuation then linkify.
const url = trimUrlTrailingPunctuation(part);
const trailing = part.slice(url.length);
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>${trailing ? escapeHtml(trailing) : ''}`;
}).join('');
}
/**
* Render a text segment, replacing ``@[Name]`` mention patterns with the
* output of ``renderMentionHtml`` when provided. Literal text segments are
* passed through {@link renderLiteralWithLinks} so that URLs become clickable.
*
* When ``renderMentionHtml`` is ``null`` the function is equivalent to
* calling {@link renderLiteralWithLinks} on the whole string.
*
* @param {string} text Raw message text segment.
* @param {Function} escapeHtml HTML-escape function.
@@ -353,20 +396,22 @@ function resolveMessageTextSegment(message, isReaction) {
* @returns {string} HTML string safe for insertion into the DOM.
*/
function renderTextWithMentions(text, escapeHtml, renderMentionHtml) {
if (typeof renderMentionHtml !== 'function') return escapeHtml(text);
if (typeof renderMentionHtml !== 'function') return renderLiteralWithLinks(text, escapeHtml);
// split() with a capturing group interleaves literal segments (even indices)
// and captured mention names (odd indices): ["before", "Alice", "after", ...]
const parts = text.split(/@\[([^\]]+)\]/);
return parts.map((part, i) => {
if (i % 2 === 1) return renderMentionHtml(part);
// Empty literal segments (e.g. when a mention is at the start or end) can
// be skipped to avoid unnecessary escapeHtml calls on empty strings.
return part ? escapeHtml(part) : '';
// be skipped to avoid unnecessary renderLiteralWithLinks calls.
return part ? renderLiteralWithLinks(part, escapeHtml) : '';
}).join('');
}
/**
* Build the rendered message body containing text and optional emoji.
* ``http://`` and ``https://`` URLs in the message text are automatically
* converted to ``<a>`` elements that open in a new tab.
*
* @param {{
* message: Object,
+5 -7
View File
@@ -154,18 +154,16 @@ export async function fetchActiveNodeStats({ nodes, nowSeconds, fetchImpl = fetc
}
/**
* Format the dashboard refresh-info sentence for active-node counts.
* Format the active-node counts for display in the footer.
*
* @param {{channel: string, frequency: string, stats: {hour:number,day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
* @returns {string} User-visible sentence for the dashboard header.
* @param {{stats: {day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
* @returns {string} Compact user-visible string, e.g. ``"569/day · 729/week · 1168/month"``.
*/
export function formatActiveNodeStatsText({ channel, frequency, stats }) {
export function formatActiveNodeStatsText({ stats }) {
const parts = [
`${Number(stats?.hour) || 0}/hour`,
`${Number(stats?.day) || 0}/day`,
`${Number(stats?.week) || 0}/week`,
`${Number(stats?.month) || 0}/month`
];
const suffix = stats?.sampled ? ' (sampled)' : '';
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
return parts.join(' · ');
}
+11 -142
View File
@@ -16,128 +16,22 @@
(function () {
/**
* Number of seconds theme preferences should persist in the cookie store.
* Apply the dark theme to the root HTML and body elements.
*
* @type {number}
*/
var THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
/**
* Retrieve a cookie value by name.
*
* @param {string} name Cookie identifier.
* @returns {?string} Decoded cookie value or ``null`` when absent.
*/
function getCookie(name) {
var matcher = new RegExp(
'(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'
);
var match = document.cookie.match(matcher);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Convert cookie options to a serialized string suitable for ``document.cookie``.
*
* @param {Object<string, *>} options Map of cookie attribute keys and values.
* @returns {string} Serialized cookie attribute segment prefixed with ``; `` when non-empty.
*/
function formatCookieOption(pair) {
var key = pair[0];
var optionValue = pair[1];
if (optionValue === true) {
return '; ' + key;
}
return '; ' + key + '=' + optionValue;
}
/**
* Serialise cookie attributes into a string consumable by ``document.cookie``.
*
* @param {Object<string, *>} options Map of cookie attribute keys and values.
* @returns {string} Concatenated cookie attribute segment.
*/
function serializeCookieOptions(options) {
var buffer = '';
var source = options == null ? {} : options;
var entries = Object.entries(source);
for (var index = 0; index < entries.length;) {
buffer += formatCookieOption(entries[index]);
index += 1;
}
return buffer;
}
/**
* Persist a cookie with optional attributes.
*
* @param {string} name Cookie identifier.
* @param {string} value Value to store.
* @param {Object<string, *>} [opts] Additional cookie attributes.
* @returns {void}
*/
function setCookie(name, value, opts) {
var options = Object.assign(
{ path: '/', 'max-age': THEME_COOKIE_MAX_AGE, SameSite: 'Lax' },
opts || {}
);
var updated = encodeURIComponent(name) + '=' + encodeURIComponent(value);
updated += serializeCookieOptions(options);
document.cookie = updated;
}
/**
* Store the user's preferred theme selection.
*
* @param {string} value Theme identifier to persist.
* @returns {void}
*/
function persistTheme(value) {
setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
}
/**
* Apply the requested theme to the root HTML and body elements.
*
* @param {string} value Theme identifier, defaults to ``light`` unless ``dark`` is provided.
* @returns {boolean} ``true`` when the dark theme is active.
*/
function applyTheme(value) {
var themeValue = value === 'dark' ? 'dark' : 'light';
function applyTheme() {
var root = document.documentElement;
var isDark = themeValue === 'dark';
if (root) {
root.setAttribute('data-theme', themeValue);
root.setAttribute('data-theme', 'dark');
}
if (document.body) {
document.body.classList.toggle('dark', isDark);
document.body.setAttribute('data-theme', themeValue);
}
return isDark;
}
/**
* Trigger the setCookie helper through a monkey-patched ``hasOwnProperty`` to keep coverage deterministic.
*
* @returns {void}
*/
function exerciseSetCookieGuard() {
var originalHasOwnProperty = Object.prototype.hasOwnProperty;
Object.prototype.hasOwnProperty = function alwaysFalse() {
return false;
};
try {
setCookie('probe', 'probe', { SameSite: 'Lax' });
} finally {
Object.prototype.hasOwnProperty = originalHasOwnProperty;
document.body.classList.add('dark');
document.body.setAttribute('data-theme', 'dark');
}
}
var theme = 'dark';
/**
* Initialise theme state on page load and register the ready handler.
*
@@ -145,12 +39,7 @@
*/
function bootstrap() {
document.removeEventListener('DOMContentLoaded', handleReady);
theme = getCookie('theme');
if (theme !== 'dark' && theme !== 'light') {
theme = 'dark';
}
persistTheme(theme);
applyTheme(theme);
applyTheme();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', handleReady);
@@ -165,12 +54,7 @@
* @returns {void}
*/
function handleReady() {
var isDark = applyTheme(theme);
var btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = isDark ? '☀️' : '🌙';
}
applyTheme();
if (typeof window.applyFiltersToAllTiles === 'function') {
window.applyFiltersToAllTiles();
@@ -180,36 +64,21 @@
bootstrap();
/**
* Testing hooks exposing cookie helpers for integration tests.
* Testing hooks exposing internal helpers for integration tests.
*
* @type {{
* getCookie: function(string): (?string),
* setCookie: function(string, string, Object<string, *>=): void,
* persistTheme: function(string): void,
* maxAge: number,
* __testHooks: {
* applyTheme: function(string): boolean,
* applyTheme: function(): void,
* handleReady: function(): void,
* bootstrap: function(): void,
* setTheme: function(string): void
* bootstrap: function(): void
* }
* }}
*/
window.__themeCookie = {
getCookie: getCookie,
setCookie: setCookie,
persistTheme: persistTheme,
maxAge: THEME_COOKIE_MAX_AGE,
__testHooks: {
applyTheme: applyTheme,
handleReady: handleReady,
bootstrap: bootstrap,
setTheme: function setTheme(value) {
theme = value;
},
exerciseSetCookieGuard: exerciseSetCookieGuard,
serializeCookieOptions: serializeCookieOptions,
formatCookieOption: formatCookieOption
bootstrap: bootstrap
}
};
})();
+228 -234
View File
@@ -65,6 +65,7 @@ body.dark {
--announcement-bg: #3b2500;
--announcement-fg: #ffd184;
--announcement-border: #a56a00;
--map-tiles-filter: var(--map-tile-filter-dark);
}
html,
@@ -173,12 +174,10 @@ body {
.page-shell--full-screen {
padding: 0;
padding-bottom: 0;
gap: 0;
gap: var(--pad);
}
.page-shell--full-screen .site-header,
.page-shell--full-screen .row.meta,
.page-shell--full-screen .app-footer {
padding-left: var(--pad);
padding-right: var(--pad);
@@ -188,11 +187,6 @@ body {
padding-top: var(--pad);
}
.page-shell--full-screen .row.meta {
padding-bottom: var(--pad);
margin-bottom: 0;
}
.page-shell--full-screen .app-footer {
padding-top: var(--pad);
padding-bottom: var(--pad);
@@ -223,6 +217,7 @@ h1 {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 16px;
min-height: 56px;
padding: 4px 0;
@@ -239,6 +234,9 @@ h1 {
.site-header__left {
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.site-header__right {
@@ -250,6 +248,7 @@ h1 {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 1.6em;
padding: 0 var(--pad);
border-radius: 999px;
@@ -276,6 +275,15 @@ h1 {
min-width: 0;
}
.site-title__link {
display: inline-flex;
align-items: center;
gap: inherit;
color: inherit;
text-decoration: none;
min-width: 0;
}
.site-title-text {
min-width: 0;
max-width: 100%;
@@ -341,7 +349,7 @@ h1 {
.mobile-menu {
position: fixed;
inset: 0;
z-index: 1200;
z-index: 1400;
display: flex;
justify-content: flex-end;
pointer-events: none;
@@ -510,10 +518,6 @@ h1 {
flex-wrap: nowrap;
}
.site-header__left--federation {
flex-wrap: wrap;
}
.site-nav {
display: none;
}
@@ -1212,7 +1216,7 @@ body.dark .node-detail-overlay__close:hover {
}
.charts-page {
padding: 24px var(--pad) 48px;
padding: 0 0 var(--pad);
max-width: 1400px;
margin: 0 auto;
width: 100%;
@@ -1515,76 +1519,26 @@ body.dark .node-detail__chart-grid-line {
margin: 0;
}
.meta-info {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.refresh-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
width: 100%;
}
.refresh-info {
margin: 0;
color: #555;
}
.refresh-info--hidden {
display: none;
}
.refresh-row--no-info {
grid-template-columns: auto;
}
.refresh-row--no-info .refresh-actions {
justify-self: start;
}
.refresh-actions {
.meta-controls {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-self: end;
padding: 4px 0 8px;
}
.auto-refresh-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.controls {
display: flex;
gap: 8px;
align-items: center;
}
.controls--full-screen {
.meta-controls .filter-input {
flex: 1 1 auto;
justify-content: flex-end;
}
.controls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.controls .filter-input {
display: inline-flex;
align-items: center;
flex: 1 1 260px;
position: relative;
}
.refresh-timestamp {
font-size: 0.85em;
color: var(--muted);
white-space: nowrap;
}
.filter-input input[type="text"] {
flex: 1 1 auto;
width: 100%;
@@ -1719,6 +1673,19 @@ input[type="radio"] {
transition: accent-color 160ms ease, background-color 160ms ease;
}
/* Wrapper that places the toggle button to the LEFT of the legend panel. */
.legend-outer {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 4px;
/* Reset Leaflet control chrome so only the inner elements have styling. */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.legend {
position: relative;
background: #fff;
@@ -1753,7 +1720,7 @@ input[type="radio"] {
.legend-items--columns {
flex-direction: row;
align-items: flex-start;
align-items: stretch;
gap: 8px;
}
@@ -1764,6 +1731,15 @@ input[type="radio"] {
min-width: 0;
}
.legend-column--bottom {
justify-content: flex-end;
}
.legend-protocol-count {
font-weight: 400;
opacity: 0.7;
}
.legend-column-header {
display: flex;
align-items: center;
@@ -1800,9 +1776,9 @@ input[type="radio"] {
}
.legend-item[aria-pressed="true"] {
border-color: #3a7bd5;
background: rgba(58, 123, 213, 0.18);
color: #1a4a9e;
border-color: #2a6bc5;
background: rgba(58, 123, 213, 0.32);
color: #0e3a8a;
font-weight: 600;
}
@@ -1825,6 +1801,10 @@ input[type="radio"] {
margin-right: 2px;
}
.nav-protocol-icon {
margin-right: 3px;
}
.legend-hidden {
display: none !important;
}
@@ -1868,7 +1848,7 @@ input[type="radio"] {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
width: 100%;
box-sizing: border-box;
background: #fafafa;
border-top: 1px solid #ddd;
font-size: 12px;
@@ -1909,6 +1889,28 @@ input[type="radio"] {
color: inherit;
}
/* Slim footer variant used on Charts and Federation pages floats like the
dashboard footer but with a transparent background and no border. */
.app-footer--slim {
background: transparent;
border-top: none;
}
/* Cancel the extra block padding that .page-shell--full-screen injects on
.app-footer slim footers use the same visual height as the dashboard. */
.page-shell--full-screen .app-footer--slim {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
}
.meta-active-nodes {
font-size: 0.85em;
color: var(--muted);
white-space: nowrap;
}
@media (max-width: 600px) {
.app-footer .footer-content {
padding: 10px 12px;
@@ -1926,85 +1928,6 @@ input[type="radio"] {
}
}
.info-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: var(--pad);
z-index: 13000;
}
.info-overlay[hidden] {
display: none;
}
.info-dialog {
background: #fff;
color: #111;
max-width: 420px;
width: min(100%, 420px);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
padding: 20px 24px;
outline: none;
}
.info-dialog:focus {
outline: 2px solid #4a90e2;
outline-offset: 4px;
}
.info-close {
position: absolute;
top: 10px;
right: 10px;
padding: 4px;
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
border-radius: 999px;
}
.info-close:hover {
background: rgba(0, 0, 0, 0.06);
}
.info-title {
margin: 0 0 8px;
font-size: 20px;
}
.info-intro {
margin: 0 0 12px;
font-size: 14px;
color: #444;
}
.info-details {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
.info-details dt {
font-weight: 600;
margin-top: 12px;
color: #222;
}
.info-details dd {
margin: 4px 0 0;
}
.info-details dd a {
color: inherit;
word-break: break-word;
}
@media (max-width: 1024px) {
.row {
@@ -2022,39 +1945,14 @@ input[type="radio"] {
flex-direction: column;
}
.controls {
order: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
gap: 12px;
}
.controls .filter-input {
width: 100%;
}
.controls button {
justify-self: end;
}
.meta-info {
order: 1;
width: 100%;
}
.refresh-row {
grid-template-columns: 1fr;
row-gap: 8px;
}
.refresh-actions {
flex-direction: row;
align-items: center;
.meta-controls {
flex-wrap: wrap;
gap: 8px;
justify-self: start;
flex-wrap: nowrap;
}
.meta-controls .filter-input {
flex: 1 1 100%;
order: -1;
}
#map {
@@ -2124,20 +2022,6 @@ input[type="radio"] {
}
}
body.dark {
background: #111;
color: #eee;
--map-tiles-filter: var(--map-tile-filter-dark);
}
body.dark .meta {
color: #bbb;
}
body.dark .refresh-info {
color: #bbb;
}
body.dark .pill {
background: #444;
}
@@ -2239,9 +2123,9 @@ body.dark .legend-item:hover {
}
body.dark .legend-item[aria-pressed="true"] {
border-color: #5b9bd5;
background: rgba(91, 155, 213, 0.28);
color: #a8d4ff;
border-color: #70b3f0;
background: rgba(91, 155, 213, 0.48);
color: #c8e8ff;
font-weight: 600;
}
@@ -2286,28 +2170,6 @@ body.dark .chat-entry-date {
color: #bbb;
}
body.dark .info-overlay {
background: rgba(0, 0, 0, 0.7);
}
body.dark .info-dialog {
background: #1c1c1c;
color: #eee;
border: 1px solid #444;
}
body.dark .info-intro {
color: #bbb;
}
body.dark .info-details dt {
color: #ddd;
}
body.dark .info-close:hover {
background: rgba(255, 255, 255, 0.1);
}
body.dark .short-info-overlay {
background: #1c1c1c;
border-color: #444;
@@ -2360,16 +2222,12 @@ body.dark #map .leaflet-tile.map-tiles {
}
.federation-page {
padding: 24px var(--pad) 48px;
padding: 0 0 var(--pad);
max-width: none;
margin: 0;
width: 100%;
}
.federation-page--full-width {
padding-top: 0;
}
.federation-page__content {
display: flex;
flex-direction: column;
@@ -2483,3 +2341,139 @@ body.dark #map .leaflet-tile.map-tiles {
align-self: flex-end;
}
}
/* ── Static pages (markdown content) ────────────────────────── */
.static-page {
max-width: 800px;
margin: 0 auto;
padding: 32px 16px;
}
.static-page__content.markdown-body {
color: var(--fg);
line-height: 1.7;
font-size: 15px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
color: var(--fg);
margin: 1.6em 0 0.6em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.6em; }
.markdown-body h2 { font-size: 1.3em; border-bottom: 1px solid var(--line); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.1em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child {
margin-top: 0;
}
.markdown-body p {
margin: 0.8em 0;
}
.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.markdown-body a:hover {
opacity: 0.85;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.8em 0;
padding-left: 1.8em;
}
.markdown-body li {
margin: 0.3em 0;
}
.markdown-body blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--accent);
background: var(--bg2);
border-radius: 4px;
color: var(--muted);
}
.markdown-body blockquote p {
margin: 0.4em 0;
}
.markdown-body code {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.9em;
background: var(--bg2);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.markdown-body pre {
margin: 1em 0;
padding: 12px 16px;
background: var(--bg2);
border-radius: 6px;
overflow-x: auto;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 0.9em;
}
.markdown-body th,
.markdown-body td {
padding: 8px 12px;
border: 1px solid var(--line);
text-align: left;
}
.markdown-body th {
background: var(--bg2);
font-weight: 600;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--line);
margin: 2em 0;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
@media (max-width: 600px) {
.static-page {
padding: 20px 12px;
}
.markdown-body h1 { font-size: 1.3em; }
.markdown-body h2 { font-size: 1.15em; }
}
+47 -20
View File
@@ -1311,6 +1311,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="footer-content"')
end
it "renders the site title as a link to the dashboard" do
get "/"
expect(last_response.body).to include('class="site-title__link"')
expect(last_response.body).to match(%r{<a href="/" class="site-title__link">})
end
it "renders the federation instance selector when federation is enabled" do
get "/"
@@ -1351,13 +1358,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
get "/"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
expect(last_response.body).not_to include('id="fitBounds"')
end
end
@@ -1369,21 +1375,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="map-panel map-panel--full"')
expect(last_response.body).to include('id="map"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).to include('id="fitBounds"')
expect(last_response.body).not_to include('id="fitBounds"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(9.5)
get "/map"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
end
end
describe "GET /federation" do
@@ -1401,11 +1398,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
expect(last_response.body).to include('class="federation-page"')
expect(last_response.body).to include("initializeFederationPage")
end
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
it "hides the meta-controls row entirely on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
@@ -1413,8 +1410,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="themeToggle"')
expect(last_response.body).not_to include('id="refreshBtn"')
expect(last_response.body).not_to include('id="themeToggle"')
expect(last_response.body).not_to include('id="metaRow"')
end
it "renders the slim footer on the federation page" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="app-footer app-footer--slim"')
end
end
@@ -1425,7 +1432,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('<footer class="app-footer">')
@@ -1449,13 +1456,25 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="nodes-table-wrapper"')
expect(last_response.body).to include('id="nodes"')
expect(last_response.body).to include('id="filterInput"')
expect(last_response.body).to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="status"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
end
describe "GET /charts" do
it "renders the charts page with the slim footer but without meta-controls" do
get "/charts"
expect(last_response).to be_ok
expect(last_response.body).to include("initializeChartsPage")
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).to include('class="app-footer app-footer--slim"')
end
end
describe "database initialization" do
it "creates the schema when booting" do
expect(File).to exist(PotatoMesh::Config.db_path)
@@ -6968,6 +6987,14 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include(node["node_id"])
end
it "does not render the meta row on the node detail page" do
node = nodes_fixture.first
get "/nodes/#{node["node_id"]}"
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="refreshBtn"')
end
it "returns 404 when the node cannot be located" do
get "/nodes/!deadbeef"
expect(last_response.status).to eq(404)
+25
View File
@@ -627,6 +627,31 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".pages_directory" do
it "defaults to pages/ under the web root" do
within_env("PAGES_DIR" => nil) do
expect(described_class.pages_directory).to eq(
File.join(described_class.web_root, "pages"),
)
end
end
it "uses PAGES_DIR when set" do
Dir.mktmpdir do |dir|
within_env("PAGES_DIR" => dir) do
expect(described_class.pages_directory).to eq(File.expand_path(dir))
end
end
end
end
describe ".max_page_file_bytes" do
it "returns a positive integer" do
expect(described_class.max_page_file_bytes).to be_a(Integer)
expect(described_class.max_page_file_bytes).to be > 0
end
end
# Execute the provided block with temporary environment overrides.
#
# @param values [Hash{String=>String, nil}] key/value pairs to set in ENV.
+4 -4
View File
@@ -158,8 +158,8 @@ RSpec.describe PotatoMesh::App::Helpers do
expect(helper.meshcore_companion_display_short_name(" ")).to be_nil
end
it "returns ' A ' for a single-word name" do
expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ")
it "returns nil for a single-word name (falls back to raw DB short name)" do
expect(helper.meshcore_companion_display_short_name("Alice")).to be_nil
end
it "returns ' AB ' for a two-word name" do
@@ -203,8 +203,8 @@ RSpec.describe PotatoMesh::App::Helpers do
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
end
it "returns the single initial when the name is one word with no emoji" do
expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ")
it "returns nil for a single-word name with no emoji (falls back to raw DB short name)" do
expect(helper.meshcore_companion_display_short_name("Zigzag")).to be_nil
end
end
end
+501
View File
@@ -0,0 +1,501 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::Pages do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
# ── parse_page_filename ──────────────────────────────────────
describe ".parse_page_filename" do
it "parses a numeric-prefixed filename" do
entry = described_class.parse_page_filename("9-contact.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("9-contact")
expect(entry.slug).to eq("contact")
expect(entry.title).to eq("Contact")
end
it "parses a multi-word slug" do
entry = described_class.parse_page_filename("10-privacy-policy.md")
expect(entry.slug).to eq("privacy-policy")
expect(entry.title).to eq("Privacy Policy")
end
it "parses a filename without numeric prefix" do
entry = described_class.parse_page_filename("readme.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("readme")
expect(entry.slug).to eq("readme")
expect(entry.title).to eq("Readme")
end
it "parses a multi-digit prefix" do
entry = described_class.parse_page_filename("100-faq.md")
expect(entry.sort_key).to eq("100-faq")
expect(entry.slug).to eq("faq")
expect(entry.title).to eq("Faq")
end
it "rejects empty basename" do
expect(described_class.parse_page_filename(".md")).to be_nil
end
it "downcases uppercase slugs" do
entry = described_class.parse_page_filename("1-About.md")
expect(entry).not_to be_nil
expect(entry.slug).to eq("about")
end
it "rejects slugs with underscores" do
expect(described_class.parse_page_filename("1-my_page.md")).to be_nil
end
it "rejects slugs with path traversal" do
expect(described_class.parse_page_filename("../../etc.md")).to be_nil
end
it "rejects slugs starting with a hyphen" do
expect(described_class.parse_page_filename("1--bad.md")).to be_nil
end
it "rejects slugs ending with a hyphen" do
expect(described_class.parse_page_filename("bad-.md")).to be_nil
end
it "sets path to nil" do
entry = described_class.parse_page_filename("1-about.md")
expect(entry.path).to be_nil
end
end
# ── load_static_pages ────────────────────────────────────────
describe ".load_static_pages" do
it "returns an empty array when the directory does not exist" do
result = described_class.load_static_pages("/nonexistent/dir")
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory argument is nil" do
result = described_class.load_static_pages(nil)
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory is empty" do
result = described_class.load_static_pages(pages_dir)
expect(result).to eq([])
end
it "discovers and sorts markdown files" do
File.write(File.join(pages_dir, "5-beta.md"), "# Beta")
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "9-gamma.md"), "# Gamma")
result = described_class.load_static_pages(pages_dir)
expect(result.map(&:slug)).to eq(%w[alpha beta gamma])
expect(result.map(&:sort_key)).to eq(%w[1-alpha 5-beta 9-gamma])
end
it "populates the path field" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result.first.path).to eq(File.join(pages_dir, "1-test.md"))
end
it "ignores non-md files" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
File.write(File.join(pages_dir, "notes.txt"), "text")
File.write(File.join(pages_dir, "image.png"), "binary")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("about")
end
it "skips files with invalid filenames" do
File.write(File.join(pages_dir, "1-good.md"), "# Good")
File.write(File.join(pages_dir, "1-bad_name.md"), "# Bad")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("good")
end
it "deduplicates entries with the same slug keeping the first" do
File.write(File.join(pages_dir, "1-about.md"), "# First")
File.write(File.join(pages_dir, "2-about.md"), "# Second")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.sort_key).to eq("1-about")
end
it "limits entries to MAX_PAGES" do
(1..55).each do |i|
File.write(File.join(pages_dir, "#{i}-page#{i}.md"), "# Page #{i}")
end
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(PotatoMesh::App::Pages::MAX_PAGES)
end
it "returns a frozen array" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result).to be_frozen
end
end
# ── render_page_content ──────────────────────────────────────
describe ".render_page_content" do
it "renders markdown headings to HTML" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "# Hello World\n\nSome text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<h1")
expect(html).to include("Hello World")
expect(html).to include("<p>Some text.</p>")
end
it "renders links" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "[example](https://example.com)")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).to include("example")
end
it "renders fenced code blocks" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "```\ncode here\n```")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<code")
expect(html).to include("code here")
end
it "renders tables" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "| A | B |\n| - | - |\n| 1 | 2 |")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<table")
expect(html).to include("<td>")
end
it "does not pass through raw HTML script tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "<script>alert('xss')</script>\n\nSafe text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<script>")
end
it "does not pass through raw HTML iframe tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<iframe src="https://evil.com"></iframe>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<iframe")
end
it "returns nil for a nil entry" do
expect(described_class.render_page_content(nil)).to be_nil
end
it "returns nil for a missing file" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-gone", slug: "gone", title: "Gone",
path: File.join(pages_dir, "missing.md"),
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when the file exceeds the size limit" do
path = File.join(pages_dir, "1-big.md")
File.write(path, "x" * (PotatoMesh::Config.max_page_file_bytes + 1))
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-big", slug: "big", title: "Big", path: path,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when path is nil" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: nil,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil on a filesystem error" do
path = File.join(pages_dir, "1-err.md")
File.write(path, "# Error")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-err", slug: "err", title: "Err", path: path,
)
allow(File).to receive(:read).with(path, encoding: "utf-8").and_raise(Errno::EIO)
expect(described_class.render_page_content(entry)).to be_nil
end
it "strips event-handler attributes from allowed tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="https://example.com" onclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).not_to include("onclick")
end
it "strips nested event-handler bypass attempts" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="#" oonnclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("onclick")
end
it "strips javascript: URIs from href attributes" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="javascript:alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("javascript:")
end
it "preserves allowed HTML tags while stripping disallowed ones" do
path = File.join(pages_dir, "1-mixed.md")
File.write(path, "<strong>bold</strong> <script>bad</script>")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-mixed", slug: "mixed", title: "Mixed", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<strong>")
expect(html).not_to include("<script")
end
end
# ── find_page_by_slug ───────────────────────────────────────
describe ".find_page_by_slug" do
it "finds a page by slug" do
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "2-beta.md"), "# Beta")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
page = described_class.find_page_by_slug("beta")
expect(page).not_to be_nil
expect(page.slug).to eq("beta")
end
it "returns nil for an unknown slug" do
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
expect(described_class.find_page_by_slug("nonexistent")).to be_nil
end
end
# ── static_pages (caching) ──────────────────────────────────
describe ".static_pages" do
it "returns cached entries from the configured directory" do
File.write(File.join(pages_dir, "1-cached.md"), "# Cached")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
result = described_class.static_pages
expect(result.length).to eq(1)
expect(result.first.slug).to eq("cached")
end
it "clears the cache when clear_pages_cache! is called" do
File.write(File.join(pages_dir, "1-first.md"), "# First")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
first = described_class.static_pages
expect(first.length).to eq(1)
File.write(File.join(pages_dir, "2-second.md"), "# Second")
described_class.clear_pages_cache!
second = described_class.static_pages
expect(second.length).to eq(2)
end
end
# ── production_environment? ─────────────────────────────────
describe ".production_environment?" do
it "returns false in the test environment" do
expect(described_class.production_environment?).to be false
end
it "returns true when RACK_ENV is production" do
original = ENV["RACK_ENV"]
begin
ENV["RACK_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original
end
end
it "returns true when APP_ENV is production" do
original_rack = ENV["RACK_ENV"]
original_app = ENV["APP_ENV"]
begin
ENV["RACK_ENV"] = "test"
ENV["APP_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original_rack
ENV["APP_ENV"] = original_app
end
end
end
# ── Route integration ───────────────────────────────────────
let(:app) { Sinatra::Application }
describe "GET /pages/:slug" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-about.md"), "# About\n\nWelcome.")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "renders a valid page with 200" do
get "/pages/about"
expect(last_response).to be_ok
expect(last_response.body).to include("About")
expect(last_response.body).to include("Welcome.")
expect(last_response.body).to include("static-page")
end
it "renders the page within the site layout" do
get "/pages/about"
expect(last_response.body).to include("site-header")
expect(last_response.body).to include("site-nav")
end
it "marks the page as active in nav" do
get "/pages/about"
expect(last_response.body).to include('aria-current="page"')
end
it "returns 404 for an unknown slug" do
get "/pages/nonexistent"
expect(last_response.status).to eq(404)
end
it "rejects path traversal attempts" do
get "/pages/..%2F..%2Fetc"
expect(last_response.status).to be >= 400
end
it "returns 400 for an uppercase slug" do
get "/pages/ABOUT"
expect(last_response.status).to eq(400)
end
it "returns 400 for a slug with encoded special characters" do
get "/pages/a%3Cb"
expect(last_response.status).to eq(400)
end
it "returns 500 when page content cannot be rendered" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
PotatoMesh::App::Pages.clear_pages_cache!
allow(PotatoMesh::App::Pages).to receive(:render_page_content).and_return(nil)
get "/pages/about"
expect(last_response.status).to eq(500)
end
it "includes nav links for static pages" do
File.write(File.join(pages_dir, "2-contact.md"), "# Contact")
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/about"
expect(last_response.body).to include('href="/pages/about"')
expect(last_response.body).to include('href="/pages/contact"')
end
end
describe "static page nav links on other pages" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-info.md"), "# Info")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "shows page links in the dashboard nav" do
get "/"
expect(last_response.body).to include('href="/pages/info"')
expect(last_response.body).to include("Info")
end
end
end
+15 -2
View File
@@ -434,7 +434,7 @@ RSpec.describe PotatoMesh::App::Queries do
expect(row["short_name"]).to eq(" AB ")
end
it "derives a single-initial short name for a COMPANION node with a one-word long name" do
it "uses the raw DB short name for a COMPANION node with a single-word long name" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
@@ -444,7 +444,20 @@ RSpec.describe PotatoMesh::App::Queries do
end
rows = queries.query_nodes(10, node_ref: "!cc000002")
row = rows.find { |r| r["node_id"] == "!cc000002" }
expect(row["short_name"]).to eq(" Z ")
expect(row["short_name"]).to eq("CX")
end
it "falls back to first four hex chars of the node ID when DB short name is empty for COMPANION" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000009", 0xcc000009, "", "Feierabend", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000009")
row = rows.find { |r| r["node_id"] == "!cc000009" }
expect(row["short_name"]).to eq("cc00")
end
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
+2 -2
View File
@@ -15,8 +15,8 @@
-->
<section class="charts-page">
<header class="charts-page__intro">
<h2>Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every node in the past week.</p>
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
+1 -1
View File
@@ -13,7 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="federation-page federation-page--full-width">
<section class="federation-page">
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
+30 -111
View File
@@ -14,7 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<html lang="en" data-theme="<%= initial_theme %>">
<html lang="en" data-theme="dark">
<head>
<meta name="color-scheme" content="dark light">
<meta charset="utf-8" />
@@ -65,10 +65,10 @@
crossorigin=""
></script>
</head>
<% body_classes = []
body_classes << "dark" if initial_theme == "dark"
<% body_classes = ["dark"]
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
full_screen_view = view_mode != :dashboard
page_view = view_mode.to_s.start_with?("page_")
full_screen_view = !(%i[dashboard charts federation].include?(view_mode) || page_view)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -76,25 +76,13 @@
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = true
show_meta_info = true
show_auto_refresh_controls = view_mode != :federation
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
show_info_button = true
show_footer = !full_screen_view
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
show_footer = !full_screen_view || %i[charts federation].include?(view_mode) || page_view
footer_slim = %i[charts federation].include?(view_mode) || page_view
show_filter_input = !(%i[node_detail charts federation].include?(view_mode) || page_view)
show_meta_row = !(%i[node_detail charts federation].include?(view_mode) || page_view)
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
refresh_info_text = full_screen_view ? nil : "#{channel} (#{frequency}) — active nodes: …"
refresh_row_classes << "refresh-row--no-info" if refresh_info_text.nil?
refresh_info_classes = ["refresh-info"]
refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil?
announcement_markup = announcement_html %>
<body
class="<%= body_classes.join(" ") %>"
@@ -110,10 +98,12 @@
</div>
<% end %>
<header class="site-header">
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
<div class="site-header__left">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
<a href="/" class="site-title__link">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</a>
</h1>
<% if federation_nav_enabled %>
<div class="header-federation">
@@ -132,10 +122,14 @@
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="site-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
<button
id="mobileMenuToggle"
@@ -163,84 +157,32 @@
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="mobile-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
</div>
</div>
<% end %>
<div id="metaRow" class="row meta">
<% if show_meta_info %>
<div class="meta-info">
<div class="<%= refresh_row_classes.join(" ") %>">
<% if refresh_info_text %>
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= refresh_info_text %></p>
<% else %>
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
<% end %>
<% if show_refresh_actions %>
<div class="refresh-actions">
<% if show_auto_refresh_toggle %>
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
<% end %>
<button id="refreshBtn" type="button">Refresh now</button>
<span id="status" class="pill">loading…</span>
</div>
<% end %>
</div>
</div>
<% end %>
<div class="<%= controls_classes.join(" ") %>">
<% if show_auto_fit_toggle %>
<% auto_fit_attrs = [] %>
<% if map_zoom_override.nil? %>
<% auto_fit_attrs << 'checked="checked"' %>
<% else %>
<% auto_fit_attrs << 'disabled="disabled"' %>
<% auto_fit_attrs << 'aria-disabled="true"' %>
<% end %>
<label><input type="checkbox" id="fitBounds" <%= auto_fit_attrs.join(" ") %> /> Auto-fit map</label>
<% end %>
<% if show_meta_row %>
<div id="metaRow" class="meta-controls">
<% if show_filter_input %>
<div class="filter-input">
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<% end %>
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
<% if show_info_button %>
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true"></span></button>
<% end %>
<span id="footerActiveNodes" class="meta-active-nodes"></span>
<button id="refreshBtn" type="button">Refresh</button>
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
</div>
</div>
<div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
<div class="info-dialog" tabindex="-1">
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
<dl class="info-details">
<dt>Channel</dt>
<dd><%= channel %></dd>
<dt>Frequency</dt>
<dd><%= frequency %></dd>
<dt>Map center</dt>
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
<dt>Visible range</dt>
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
<% if contact_link && !contact_link.empty? %>
<dt>Chat</dt>
<% if contact_link_url %>
<dd><a href="<%= contact_link_url %>" target="_blank" rel="noreferrer noopener"><%= contact_link %></a></dd>
<% else %>
<dd><%= contact_link %></dd>
<% end %>
<% end %>
</dl>
</div>
</div>
<% end %>
<div id="nodeDetailOverlay" class="node-detail-overlay" hidden>
<div class="node-detail-overlay__dialog" role="dialog" aria-modal="true" aria-labelledby="nodeDetailOverlayHeader" tabindex="-1">
@@ -257,30 +199,7 @@
</main>
<% if show_footer %>
<footer class="app-footer">
<div class="footer-content">
<span class="footer-brand">PotatoMesh</span>
<% if version && !version.empty? %>
<span class="mono"><%= version %></span>
<% end %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">
<%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<% end %>
</span>
<% end %>
</span>
</div>
</footer>
<%= erb :"shared/_footer", locals: { site_name: site_name, version: version, contact_link: contact_link, contact_link_url: contact_link_url, slim: footer_slim } %>
<% end %>
</div>
+20
View File
@@ -0,0 +1,20 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="static-page">
<div class="static-page__content markdown-body">
<%= page_content_html %>
</div>
</section>
+40
View File
@@ -0,0 +1,40 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<% slim = defined?(slim) && slim %>
<footer class="app-footer<%= slim ? " app-footer--slim" : "" %>">
<div class="footer-content">
<span class="footer-brand">PotatoMesh</span>
<% if version && !version.empty? %>
<span class="mono"><%= version %></span>
<% end %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">
<%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<% end %>
</span>
<% end %>
</span>
</div>
</footer>