diff --git a/DOCKER.md b/DOCKER.md index 2c1d1be..b82e525 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -47,6 +47,7 @@ Additional environment variables are optional: | `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. | | `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix room alias rendered in UI footers and overlays. | | `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map view. | +| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). | | `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. | | `DEBUG` | `0` | Enables verbose logging across services when set to `1`. | | `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). | diff --git a/Dockerfile b/Dockerfile index 9bc74ce..8819249 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,6 +84,7 @@ ENV APP_ENV=production \ CHANNEL="#LongFast" \ FREQUENCY="915MHz" \ MAP_CENTER="38.761944,-27.090833" \ + MAP_ZOOM="" \ MAX_DISTANCE=42 \ CONTACT_LINK="#potatomesh:dod.ngo" \ DEBUG=0 diff --git a/README.md b/README.md index 3cc89fa..ced1641 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ The web app can be configured with environment variables (defaults shown): | `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. | | `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. | | `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. | +| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. | | `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. | | `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. | | `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. | @@ -92,7 +93,7 @@ logo for Open Graph and Twitter cards. Example: ```bash -SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh +SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAP_ZOOM=11 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh ``` ### Configuration & Storage diff --git a/configure.sh b/configure.sh index 07c2ebe..bdcbeca 100755 --- a/configure.sh +++ b/configure.sh @@ -77,6 +77,7 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1") PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0") MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833") +MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42") CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo") API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") @@ -90,6 +91,7 @@ echo "📍 Location Settings" echo "-------------------" read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME read_with_default "Map Center (lat,lon)" "$MAP_CENTER" MAP_CENTER +read_with_default "Default map zoom (leave blank to auto-fit)" "$MAP_ZOOM" MAP_ZOOM read_with_default "Max Distance (km)" "$MAX_DISTANCE" MAX_DISTANCE echo "" @@ -180,6 +182,11 @@ update_env "SITE_NAME" "\"$SITE_NAME\"" update_env "CHANNEL" "\"$CHANNEL\"" update_env "FREQUENCY" "\"$FREQUENCY\"" update_env "MAP_CENTER" "\"$MAP_CENTER\"" +if [ -n "$MAP_ZOOM" ]; then + update_env "MAP_ZOOM" "$MAP_ZOOM" +else + sed -i.bak '/^MAP_ZOOM=.*/d' .env +fi update_env "MAX_DISTANCE" "$MAX_DISTANCE" update_env "CONTACT_LINK" "\"$CONTACT_LINK\"" update_env "DEBUG" "$DEBUG" @@ -222,6 +229,11 @@ echo "" echo "📋 Your settings:" echo " Site Name: $SITE_NAME" echo " Map Center: $MAP_CENTER" +if [ -n "$MAP_ZOOM" ]; then + echo " Map Zoom: $MAP_ZOOM" +else + echo " Map Zoom: Auto-fit" +fi echo " Max Distance: ${MAX_DISTANCE}km" echo " Channel: $CHANNEL" echo " Frequency: $FREQUENCY" diff --git a/data/__init__.py b/data/__init__.py index 127c05b..f41d18d 100644 --- a/data/__init__.py +++ b/data/__init__.py @@ -17,3 +17,8 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and message information before forwarding it to the accompanying web application. """ + +VERSION = "0.5.5" +"""Semantic version identifier shared with the dashboard and front-end.""" + +__version__ = VERSION diff --git a/docker-compose.yml b/docker-compose.yml index 02e52c6..7f3ceaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ x-web-base: &web-base CHANNEL: ${CHANNEL:-#LongFast} FREQUENCY: ${FREQUENCY:-915MHz} MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833} + MAP_ZOOM: ${MAP_ZOOM:-""} MAX_DISTANCE: ${MAX_DISTANCE:-42} CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo} FEDERATION: ${FEDERATION:-1} diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py new file mode 100644 index 0000000..90f1aef --- /dev/null +++ b/tests/test_version_sync.py @@ -0,0 +1,69 @@ +# 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. + +"""Ensure version identifiers stay synchronised across all packages.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import data + + +def _ruby_fallback_version() -> str: + config_path = REPO_ROOT / "web" / "lib" / "potato_mesh" / "config.rb" + contents = config_path.read_text(encoding="utf-8") + inside = False + for line in contents.splitlines(): + stripped = line.strip() + if stripped.startswith("def version_fallback"): + inside = True + continue + if inside and stripped == "end": + break + if inside: + literal = re.search(r"['\"](?P[^'\"]+)['\"]", stripped) + if literal: + return literal.group("version") + raise AssertionError("Unable to locate version_fallback definition in config.rb") + + +def _javascript_package_version() -> str: + package_path = REPO_ROOT / "web" / "package.json" + data = json.loads(package_path.read_text(encoding="utf-8")) + version = data.get("version") + if isinstance(version, str): + return version + raise AssertionError("package.json does not expose a string version") + + +def test_version_identifiers_match_across_languages() -> None: + """Guard against version drift between Python, Ruby, and JavaScript.""" + + python_version = getattr(data, "__version__", None) + assert ( + isinstance(python_version, str) and python_version + ), "data.__version__ missing" + + ruby_version = _ruby_fallback_version() + javascript_version = _javascript_package_version() + + assert python_version == ruby_version == javascript_version diff --git a/web/Dockerfile b/web/Dockerfile index 8be5f57..b50b0ea 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -91,6 +91,7 @@ ENV RACK_ENV=production \ CHANNEL="#LongFast" \ FREQUENCY="915MHz" \ MAP_CENTER="38.761944,-27.090833" \ + MAP_ZOOM="" \ MAX_DISTANCE=42 \ CONTACT_LINK="#potatomesh:dod.ngo" \ DEBUG=0 diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index 79bd6dd..837fc45 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -122,6 +122,7 @@ module PotatoMesh lat: PotatoMesh::Config.map_center_lat, lon: PotatoMesh::Config.map_center_lon, }, + mapZoom: PotatoMesh::Config.map_zoom, maxDistanceKm: PotatoMesh::Config.max_distance_km, tileFilters: PotatoMesh::Config.tile_filters, instanceDomain: app_constant(:INSTANCE_DOMAIN), @@ -173,6 +174,18 @@ module PotatoMesh "/nodes/!#{escaped}" end + # Present a version string with a leading ``v`` when missing to keep + # UI labels consistent across tagged and fallback builds. + # + # @param version [String, nil] raw application version string. + # @return [String, nil] version string prefixed with ``v`` when needed. + def display_version(version) + return nil if version.nil? || version.to_s.strip.empty? + + text = version.to_s.strip + text.start_with?("v") ? text : "v#{text}" + end + # Render a linked long name pointing to the node detail page. # # @param long_name [String] display name for the node. diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 7286acc..140980f 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -62,13 +62,14 @@ module PotatoMesh max_distance_km: PotatoMesh::Config.max_distance_km, contact_link: sanitized_contact_link, contact_link_url: sanitized_contact_link_url, - version: app_constant(:APP_VERSION), + version: display_version(app_constant(:APP_VERSION)), private_mode: private_mode?, federation_enabled: federation_enabled?, refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, app_config_json: JSON.generate(config), initial_theme: theme, current_view_mode: view_mode_sym, + map_zoom: PotatoMesh::Config.map_zoom, } sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {} merged_locals = base_locals.merge(sanitized_locals) diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 36fba3a..d04a44d 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -175,7 +175,7 @@ module PotatoMesh # # @return [String] semantic version identifier. def version_fallback - "v0.5.5" + "0.5.5" end # Default refresh interval for frontend polling routines. @@ -477,6 +477,20 @@ module PotatoMesh map_center[:lon] end + # Retrieve an explicit map zoom override when provided. + # + # @return [Float, nil] positive zoom value or +nil+ when unset. + def map_zoom + raw = fetch_string("MAP_ZOOM", nil) + return nil unless raw + + zoom = Float(raw, exception: false) + return nil unless zoom + return nil unless zoom.positive? + + zoom + end + # Maximum straight-line distance between nodes before relationships are # hidden. # diff --git a/web/package-lock.json b/web/package-lock.json index 2f4a1ce..e5a656a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "potato-mesh", - "version": "0.5.0", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "potato-mesh", - "version": "0.5.0", + "version": "0.5.5", "devDependencies": { "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", diff --git a/web/package.json b/web/package.json index efb8d25..3151e1f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "potato-mesh", - "version": "0.5.0", + "version": "0.5.5", "type": "module", "private": true, "scripts": { diff --git a/web/public/assets/js/app/__tests__/config.test.js b/web/public/assets/js/app/__tests__/config.test.js index d1c7a2f..08b2515 100644 --- a/web/public/assets/js/app/__tests__/config.test.js +++ b/web/public/assets/js/app/__tests__/config.test.js @@ -81,6 +81,7 @@ test('mergeConfig coerces numeric values and nested objects', () => { refreshMs: '45000', mapCenter: { lat: '10.5', lon: '20.1' }, tileFilters: { dark: 'contrast(2)' }, + mapZoom: '12', chatEnabled: 0, channel: '#Custom', frequency: '915MHz', @@ -93,6 +94,7 @@ test('mergeConfig coerces numeric values and nested objects', () => { assert.equal(result.refreshMs, 45000); assert.deepEqual(result.mapCenter, { lat: 10.5, lon: 20.1 }); assert.deepEqual(result.tileFilters, { light: DEFAULT_CONFIG.tileFilters.light, dark: 'contrast(2)' }); + assert.equal(result.mapZoom, 12); assert.equal(result.chatEnabled, false); assert.equal(result.channel, '#Custom'); assert.equal(result.frequency, '915MHz'); @@ -105,12 +107,19 @@ test('mergeConfig falls back to defaults for invalid numeric values', () => { const result = mergeConfig({ refreshIntervalSeconds: 'NaN', refreshMs: 'NaN', - maxDistanceKm: 'oops' + maxDistanceKm: 'oops', + mapZoom: 'not-a-number' }); assert.equal(result.refreshIntervalSeconds, DEFAULT_CONFIG.refreshIntervalSeconds); assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs); assert.equal(result.maxDistanceKm, DEFAULT_CONFIG.maxDistanceKm); + assert.equal(result.mapZoom, null); +}); + +test('mergeConfig treats blank mapZoom as null', () => { + const result = mergeConfig({ mapZoom: '' }); + assert.equal(result.mapZoom, null); }); test('document stub returns null for unrelated selectors', () => { diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 40b6775..7e8aff5 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -68,6 +68,7 @@ import { normalizeNodeCollection } from './node-snapshot-normalizer.js'; * channel: string, * frequency: string, * mapCenter: { lat: number, lon: number }, + * mapZoom: number | null, * maxDistanceKm: number, * tileFilters: { light: string, dark: string } * }} config Normalized application configuration. @@ -105,6 +106,7 @@ export function initializeApp(config) { : false; const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false; const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false; + const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null; /** * Column sorter configuration for the node table. * @@ -394,6 +396,12 @@ let messagesById = new Map(); } } + 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'); @@ -1252,7 +1260,9 @@ let messagesById = new Map(); LIMIT_DISTANCE ? MAX_DISTANCE_KM : null, { minimumRangeKm: 1 } ); - if (initialBounds) { + if (mapZoomOverride !== null) { + map.setView([MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], mapZoomOverride); + } else if (initialBounds) { fitMapToBounds(initialBounds, { animate: false, paddingPx: INITIAL_VIEW_PADDING_PX, maxZoom: MAX_INITIAL_ZOOM }); } else if (mapCenterLatLng) { map.setView(mapCenterLatLng, 10); diff --git a/web/public/assets/js/app/settings.js b/web/public/assets/js/app/settings.js index 472e2f5..bf3584c 100644 --- a/web/public/assets/js/app/settings.js +++ b/web/public/assets/js/app/settings.js @@ -26,6 +26,7 @@ * contactLink: string, * contactLinkUrl: string | null, * mapCenter: { lat: number, lon: number }, + * mapZoom: number | null, * maxDistanceKm: number, * tileFilters: { light: string, dark: string } * }} @@ -39,6 +40,7 @@ export const DEFAULT_CONFIG = { contactLink: '#potatomesh:dod.ngo', contactLinkUrl: 'https://matrix.to/#/#potatomesh:dod.ngo', mapCenter: { lat: 38.761944, lon: -27.090833 }, + mapZoom: null, maxDistanceKm: 42, tileFilters: { light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)', @@ -79,5 +81,12 @@ export function mergeConfig(raw) { config.maxDistanceKm = Number.isFinite(maxDistance) ? maxDistance : DEFAULT_CONFIG.maxDistanceKm; + const mapZoomValue = raw?.mapZoom; + if (mapZoomValue == null || mapZoomValue === '') { + config.mapZoom = null; + } else { + const zoom = Number(mapZoomValue); + config.mapZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : null; + } return config; } diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 8eb492d..d7e5c42 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -1141,7 +1141,8 @@ RSpec.describe "Potato Mesh Sinatra app" do it "includes the application version in the footer" do get "/" - expect(last_response.body).to include("#{APP_VERSION}") + expected = APP_VERSION.to_s.start_with?("v") ? APP_VERSION : "v#{APP_VERSION}" + expect(last_response.body).to include(expected) end it "renders the responsive footer container" do @@ -1190,6 +1191,15 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response.body).to include('') expect(last_response.body).to include('') end + + it "disables the auto-fit toggle when a map zoom override is configured" 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"') + end end describe "GET /map" do @@ -1206,6 +1216,15 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response.body).to include('id="fitBounds"') expect(last_response.body).not_to include('