diff --git a/Dockerfile b/Dockerfile index bdd9d4a..dee636c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build # Stage 2: Python runtime FROM python:3.12-slim +ARG COMMIT_HASH=unknown + WORKDIR /app +ENV COMMIT_HASH=${COMMIT_HASH} + # Install uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index b4ba38f..eae5c42 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -12,7 +12,6 @@ from __future__ import annotations import asyncio import base64 import hashlib -import importlib.metadata import json import logging import ssl @@ -25,12 +24,13 @@ import nacl.bindings from app.fanout.mqtt_base import BaseMqttPublisher from app.path_utils import parse_packet_envelope, split_path_hex +from app.version_info import get_app_build_info logger = logging.getLogger(__name__) _DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net" _DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default -_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)" +_CLIENT_ID = "RemoteTerm" # Proactive JWT renewal: reconnect 1 hour before the 24h token expires _TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp) @@ -261,11 +261,7 @@ def _build_radio_info() -> str: def _get_client_version() -> str: """Return a client version string like ``'RemoteTerm 2.4.0'``.""" - try: - version = importlib.metadata.version("remoteterm-meshcore") - return f"RemoteTerm {version}" - except Exception: - return "RemoteTerm unknown" + return f"RemoteTerm {get_app_build_info().version}" class CommunityMqttPublisher(BaseMqttPublisher): diff --git a/app/fanout/mqtt_base.py b/app/fanout/mqtt_base.py index db8ea6f..f9c725d 100644 --- a/app/fanout/mqtt_base.py +++ b/app/fanout/mqtt_base.py @@ -54,6 +54,17 @@ class BaseMqttPublisher(ABC): self._settings_version: int = 0 self._version_event: asyncio.Event = asyncio.Event() self.connected: bool = False + self.integration_name: str = "" + + def set_integration_name(self, name: str) -> None: + """Attach the configured fanout-module name for operator-facing logs.""" + self.integration_name = name.strip() + + def _integration_label(self) -> str: + """Return a concise label for logs, including the configured module name.""" + if self.integration_name: + return f"{self._log_prefix} [{self.integration_name}]" + return self._log_prefix # ── Lifecycle ────────────────────────────────────────────────────── @@ -90,8 +101,9 @@ class BaseMqttPublisher(ABC): await self._client.publish(topic, json.dumps(payload), retain=retain) except Exception as e: logger.warning( - "%s publish failed on %s: %s", - self._log_prefix, + "%s publish failed on %s. This is usually transient network noise; " + "if it self-resolves and reconnects, it is generally not a concern: %s", + self._integration_label(), topic, e, exc_info=True, @@ -225,8 +237,10 @@ class BaseMqttPublisher(ABC): broadcast_error(title, detail) _broadcast_health() logger.warning( - "%s connection error: %s (reconnecting in %ds)", - self._log_prefix, + "%s connection error. This is usually transient network noise; " + "if it self-resolves, it is generally not a concern: %s " + "(reconnecting in %ds)", + self._integration_label(), e, backoff, exc_info=True, diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py index 0fea530..d17971b 100644 --- a/app/fanout/mqtt_community.py +++ b/app/fanout/mqtt_community.py @@ -77,6 +77,7 @@ class MqttCommunityModule(FanoutModule): def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: super().__init__(config_id, config, name=name) self._publisher = CommunityMqttPublisher() + self._publisher.set_integration_name(name or config_id) async def start(self) -> None: settings = _config_to_settings(self.config) diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py index 2169589..19e49ae 100644 --- a/app/fanout/mqtt_private.py +++ b/app/fanout/mqtt_private.py @@ -32,6 +32,7 @@ class MqttPrivateModule(FanoutModule): def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: super().__init__(config_id, config, name=name) self._publisher = MqttPublisher() + self._publisher.set_integration_name(name or config_id) async def start(self) -> None: settings = _config_to_settings(self.config) diff --git a/app/main.py b/app/main.py index 2a03f5d..fa509fc 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,7 @@ from app.routers import ( ) from app.security import add_optional_basic_auth_middleware from app.services.radio_runtime import radio_runtime as radio_manager +from app.version_info import get_app_build_info setup_logging() logger = logging.getLogger(__name__) @@ -102,22 +103,10 @@ async def lifespan(app: FastAPI): await db.disconnect() -def _get_version() -> str: - """Read version from pyproject.toml so it stays in sync automatically.""" - try: - pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" - for line in pyproject.read_text().splitlines(): - if line.startswith("version = "): - return line.split('"')[1] - except Exception: - pass - return "0.0.0" - - app = FastAPI( title="RemoteTerm for MeshCore API", description="API for interacting with MeshCore mesh radio networks", - version=_get_version(), + version=get_app_build_info().version, lifespan=lifespan, ) diff --git a/app/routers/debug.py b/app/routers/debug.py index aa5f457..a290a94 100644 --- a/app/routers/debug.py +++ b/app/routers/debug.py @@ -1,11 +1,7 @@ import hashlib -import importlib.metadata -import json import logging -import subprocess import sys from datetime import datetime, timezone -from pathlib import Path from typing import Any from fastapi import APIRouter @@ -17,6 +13,7 @@ from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_chann from app.repository import MessageRepository from app.routers.health import HealthResponse, build_health_data from app.services.radio_runtime import radio_runtime +from app.version_info import get_app_build_info, git_output logger = logging.getLogger(__name__) @@ -24,7 +21,6 @@ router = APIRouter(tags=["debug"]) LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW" LOG_COPY_BOUNDARY_LINE = "-" * 64 -RELEASE_BUILD_INFO_FILENAME = "build_info.json" LOG_COPY_BOUNDARY_PREFIX = [ LOG_COPY_BOUNDARY_LINE, LOG_COPY_BOUNDARY_LINE, @@ -40,7 +36,9 @@ LOG_COPY_BOUNDARY_PREFIX = [ class DebugApplicationInfo(BaseModel): version: str + version_source: str commit_hash: str | None = None + commit_source: str | None = None git_branch: str | None = None git_dirty: bool | None = None python_version: str @@ -97,64 +95,15 @@ class DebugSnapshotResponse(BaseModel): logs: list[str] -def _repo_root() -> Path: - return Path(__file__).resolve().parents[2] - - -def _get_app_version() -> str: - try: - return importlib.metadata.version("remoteterm-meshcore") - except Exception: - pyproject = _repo_root() / "pyproject.toml" - try: - for line in pyproject.read_text().splitlines(): - if line.startswith("version = "): - return line.split('"')[1] - except Exception: - pass - return "0.0.0" - - -def _git_output(*args: str) -> str | None: - try: - result = subprocess.run( - ["git", *args], - cwd=_repo_root(), - check=True, - capture_output=True, - text=True, - ) - except Exception: - return None - output = result.stdout.strip() - return output or None - - -def _release_build_info() -> dict[str, Any] | None: - build_info_path = _repo_root() / RELEASE_BUILD_INFO_FILENAME - try: - data = json.loads(build_info_path.read_text()) - except Exception: - return None - - if isinstance(data, dict): - return data - return None - - def _build_application_info() -> DebugApplicationInfo: - release_build_info = _release_build_info() - dirty_output = _git_output("status", "--porcelain") - commit_hash = _git_output("rev-parse", "HEAD") - if commit_hash is None and release_build_info is not None: - commit_hash_value = release_build_info.get("commit_hash") - if isinstance(commit_hash_value, str) and commit_hash_value.strip(): - commit_hash = commit_hash_value.strip() - + build_info = get_app_build_info() + dirty_output = git_output("status", "--porcelain") return DebugApplicationInfo( - version=_get_app_version(), - commit_hash=commit_hash, - git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"), + version=build_info.version, + version_source=build_info.version_source, + commit_hash=build_info.commit_hash, + commit_source=build_info.commit_source, + git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"), git_dirty=(dirty_output is not None and dirty_output != ""), python_version=sys.version.split()[0], ) diff --git a/app/routers/health.py b/app/routers/health.py index 226202a..2ceb39e 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from app.config import settings from app.repository import RawPacketRepository from app.services.radio_runtime import radio_runtime as radio_manager +from app.version_info import get_app_build_info router = APIRouter(tags=["health"]) @@ -19,12 +20,18 @@ class RadioDeviceInfoResponse(BaseModel): max_channels: int | None = None +class AppInfoResponse(BaseModel): + version: str + commit_hash: str | None = None + + class HealthResponse(BaseModel): status: str radio_connected: bool radio_initializing: bool = False radio_state: str = "disconnected" connection_info: str | None + app_info: AppInfoResponse | None = None radio_device_info: RadioDeviceInfoResponse | None = None database_size_mb: float oldest_undecrypted_timestamp: int | None @@ -41,6 +48,7 @@ def _clean_optional_str(value: object) -> str | None: async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: """Build the health status payload used by REST endpoint and WebSocket broadcasts.""" + app_build_info = get_app_build_info() db_size_mb = 0.0 try: db_size_bytes = os.path.getsize(settings.database_path) @@ -102,6 +110,10 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) "radio_initializing": radio_initializing, "radio_state": radio_state, "connection_info": connection_info, + "app_info": { + "version": app_build_info.version, + "commit_hash": app_build_info.commit_hash, + }, "radio_device_info": radio_device_info, "database_size_mb": db_size_mb, "oldest_undecrypted_timestamp": oldest_ts, diff --git a/app/version_info.py b/app/version_info.py new file mode 100644 index 0000000..91c5ece --- /dev/null +++ b/app/version_info.py @@ -0,0 +1,149 @@ +"""Unified application version/build metadata resolution. + +Resolution order: +- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml`` +- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json`` + +This keeps backend surfaces, release bundles, and Docker builds aligned. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import os +import subprocess +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +import tomllib + +RELEASE_BUILD_INFO_FILENAME = "build_info.json" +PROJECT_NAME = "remoteterm-meshcore" + + +@dataclass(frozen=True) +class AppBuildInfo: + version: str + version_source: str + commit_hash: str | None + commit_source: str | None + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _read_build_info(root: Path) -> dict[str, Any] | None: + build_info_path = root / RELEASE_BUILD_INFO_FILENAME + try: + data = json.loads(build_info_path.read_text()) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _package_metadata_version() -> str | None: + try: + return importlib.metadata.version(PROJECT_NAME) + except Exception: + return None + + +def _env_version() -> str | None: + value = os.getenv("APP_VERSION") + return value.strip() if value and value.strip() else None + + +def _build_info_version(build_info: dict[str, Any] | None) -> str | None: + if not build_info: + return None + value = build_info.get("version") + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _pyproject_version(root: Path) -> str | None: + try: + pyproject = tomllib.loads((root / "pyproject.toml").read_text()) + project = pyproject.get("project") + if isinstance(project, dict): + version = project.get("version") + if isinstance(version, str) and version.strip(): + return version.strip() + except Exception: + return None + return None + + +def _git_output(root: Path, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", *args], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + except Exception: + return None + output = result.stdout.strip() + return output or None + + +def _env_commit_hash() -> str | None: + for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"): + value = os.getenv(name) + if value and value.strip(): + return value.strip()[:8] + return None + + +def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None: + if not build_info: + return None + value = build_info.get("commit_hash") + return value.strip()[:8] if isinstance(value, str) and value.strip() else None + + +@lru_cache(maxsize=1) +def get_app_build_info() -> AppBuildInfo: + root = repo_root() + build_info = _read_build_info(root) + + version = _package_metadata_version() + version_source = "package_metadata" + if version is None: + version = _env_version() + version_source = "env" + if version is None: + version = _build_info_version(build_info) + version_source = "build_info" + if version is None: + version = _pyproject_version(root) + version_source = "pyproject" + if version is None: + version = "0.0.0" + version_source = "fallback" + + commit_hash = _git_output(root, "rev-parse", "--short", "HEAD") + commit_source: str | None = "git" if commit_hash else None + if commit_hash is None: + commit_hash = _env_commit_hash() + commit_source = "env" if commit_hash else None + if commit_hash is None: + commit_hash = _build_info_commit_hash(build_info) + commit_source = "build_info" if commit_hash else None + + return AppBuildInfo( + version=version, + version_source=version_source, + commit_hash=commit_hash, + commit_source=commit_source, + ) + + +def git_output(*args: str) -> str | None: + """Shared git helper for debug surfaces that still need live repo state.""" + return _git_output(repo_root(), *args) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9b02bcb..f32c1b3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -129,8 +129,7 @@ frontend/src/ │ │ └── RepeaterConsolePane.tsx # CLI console with history │ └── ui/ # shadcn/ui primitives ├── types/ -│ ├── d3-force-3d.d.ts # Type declarations for d3-force-3d -│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__) +│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d └── test/ ├── setup.ts ├── fixtures/websocket_events.json diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 616b796..96cb9f5 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -275,7 +275,9 @@ export function SettingsModal(props: SettingsModalProps) { {shouldRenderSection('about') && (
{renderSectionHeader('about')} - {isSectionVisible('about') && } + {isSectionVisible('about') && ( + + )}
)} diff --git a/frontend/src/components/settings/SettingsAboutSection.tsx b/frontend/src/components/settings/SettingsAboutSection.tsx index 23efcaa..a3ee94b 100644 --- a/frontend/src/components/settings/SettingsAboutSection.tsx +++ b/frontend/src/components/settings/SettingsAboutSection.tsx @@ -1,10 +1,17 @@ +import type { HealthStatus } from '../../types'; import { Separator } from '../ui/separator'; const GITHUB_URL = 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore'; -export function SettingsAboutSection({ className }: { className?: string }) { - const version = __APP_VERSION__; - const commit = __COMMIT_HASH__; +export function SettingsAboutSection({ + health, + className, +}: { + health?: HealthStatus | null; + className?: string; +}) { + const version = health?.app_info?.version ?? 'unknown'; + const commit = health?.app_info?.commit_hash; return (
@@ -14,8 +21,14 @@ export function SettingsAboutSection({ className }: { className?: string }) {

RemoteTerm for MeshCore

v{version} - · - {commit} + {commit ? ( + <> + · + + {commit} + + + ) : null}
diff --git a/frontend/src/test/settingsAboutSection.test.tsx b/frontend/src/test/settingsAboutSection.test.tsx index 18a0aba..b77e828 100644 --- a/frontend/src/test/settingsAboutSection.test.tsx +++ b/frontend/src/test/settingsAboutSection.test.tsx @@ -1,20 +1,28 @@ import { render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { SettingsAboutSection } from '../components/settings/SettingsAboutSection'; describe('SettingsAboutSection', () => { - beforeEach(() => { - vi.stubGlobal('__APP_VERSION__', '3.2.0-test'); - vi.stubGlobal('__COMMIT_HASH__', 'deadbeef'); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - it('renders the debug support snapshot link', () => { - render(); + render( + + ); const link = screen.getByRole('link', { name: /Open debug support snapshot/i }); expect(link).toHaveAttribute('href', '/api/debug'); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 992fa19..a2a929c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -51,12 +51,18 @@ export interface FanoutStatusEntry { status: string; } +export interface AppInfo { + version: string; + commit_hash: string | null; +} + export interface HealthStatus { status: string; radio_connected: boolean; radio_initializing: boolean; radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused'; connection_info: string | null; + app_info?: AppInfo | null; radio_device_info?: { model: string | null; firmware_build: string | null; diff --git a/frontend/src/types/globals.d.ts b/frontend/src/types/globals.d.ts deleted file mode 100644 index 11311eb..0000000 --- a/frontend/src/types/globals.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const __APP_VERSION__: string; -declare const __COMMIT_HASH__: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 08e36f9..a5c0826 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,24 +1,9 @@ import path from "path" -import { execSync } from "child_process" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -function getCommitHash(): string { - // Docker builds pass VITE_COMMIT_HASH as an env var - if (process.env.VITE_COMMIT_HASH) return process.env.VITE_COMMIT_HASH; - try { - return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim(); - } catch { - return 'unknown'; - } -} - export default defineConfig({ plugins: [react()], - define: { - __APP_VERSION__: JSON.stringify(process.env.npm_package_version ?? 'unknown'), - __COMMIT_HASH__: JSON.stringify(getCommitHash()), - }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/scripts/publish.sh b/scripts/publish.sh index 60b8144..44fdb00 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -186,7 +186,8 @@ mkdir -p "$RELEASE_BUNDLE_DIR/frontend" cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt" cat > "$RELEASE_BUNDLE_DIR/build_info.json" <