mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
improve MQTT error bubble up and massage communitymqtt + debug etc. for version management. Closes #70
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
app/main.py
15
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
app/version_info.py
Normal file
149
app/version_info.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -275,7 +275,9 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
{shouldRenderSection('about') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('about')}
|
||||
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
|
||||
{isSectionVisible('about') && (
|
||||
<SettingsAboutSection health={health} className={sectionContentClass} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className={className}>
|
||||
@@ -14,8 +21,14 @@ export function SettingsAboutSection({ className }: { className?: string }) {
|
||||
<h3 className="text-lg font-semibold">RemoteTerm for MeshCore</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
v{version}
|
||||
{commit ? (
|
||||
<>
|
||||
<span className="mx-1.5">·</span>
|
||||
<span className="font-mono text-xs">{commit}</span>
|
||||
<span className="font-mono text-xs" title={commit}>
|
||||
{commit}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(<SettingsAboutSection />);
|
||||
render(
|
||||
<SettingsAboutSection
|
||||
health={{
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'Serial: /dev/ttyUSB0',
|
||||
app_info: {
|
||||
version: '3.2.0-test',
|
||||
commit_hash: 'deadbeef',
|
||||
},
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
|
||||
expect(link).toHaveAttribute('href', '/api/debug');
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
frontend/src/types/globals.d.ts
vendored
2
frontend/src/types/globals.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __COMMIT_HASH__: string;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" <<EOF
|
||||
{
|
||||
"commit_hash": "$FULL_GIT_HASH",
|
||||
"version": "$VERSION",
|
||||
"commit_hash": "$GIT_HASH",
|
||||
"build_source": "prebuilt-release"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -5,7 +5,6 @@ Uses httpx.AsyncClient or direct function calls with real in-memory SQLite.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -20,6 +19,7 @@ from app.repository import (
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.version_info import AppBuildInfo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -144,7 +144,9 @@ class TestDebugEndpoint:
|
||||
"app.routers.debug._build_application_info",
|
||||
return_value=DebugApplicationInfo(
|
||||
version="3.2.0",
|
||||
version_source="pyproject",
|
||||
commit_hash="deadbeef",
|
||||
commit_source="git",
|
||||
git_branch="main",
|
||||
git_dirty=False,
|
||||
python_version="3.12.0",
|
||||
@@ -186,24 +188,24 @@ class TestDebugApplicationInfo:
|
||||
"""Release bundles should still surface commit metadata without a .git directory."""
|
||||
from app.routers import debug as debug_router
|
||||
|
||||
(tmp_path / "build_info.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"commit_hash": "cf1a55e25828ee62fb077d6202b174f69f6e6340",
|
||||
"build_source": "prebuilt-release",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.debug._repo_root", return_value=tmp_path),
|
||||
patch("app.routers.debug._get_app_version", return_value="3.4.0"),
|
||||
patch("app.routers.debug._git_output", return_value=None),
|
||||
patch(
|
||||
"app.routers.debug.get_app_build_info",
|
||||
return_value=AppBuildInfo(
|
||||
version="3.4.0",
|
||||
version_source="pyproject",
|
||||
commit_hash="cf1a55e2",
|
||||
commit_source="build_info",
|
||||
),
|
||||
),
|
||||
patch("app.routers.debug.git_output", return_value=None),
|
||||
):
|
||||
info = debug_router._build_application_info()
|
||||
|
||||
assert info.version == "3.4.0"
|
||||
assert info.commit_hash == "cf1a55e25828ee62fb077d6202b174f69f6e6340"
|
||||
assert info.version_source == "pyproject"
|
||||
assert info.commit_hash == "cf1a55e2"
|
||||
assert info.commit_source == "build_info"
|
||||
assert info.git_branch is None
|
||||
assert info.git_dirty is False
|
||||
|
||||
@@ -211,17 +213,24 @@ class TestDebugApplicationInfo:
|
||||
"""Malformed release metadata should not break the debug endpoint."""
|
||||
from app.routers import debug as debug_router
|
||||
|
||||
(tmp_path / "build_info.json").write_text("{not-json")
|
||||
|
||||
with (
|
||||
patch("app.routers.debug._repo_root", return_value=tmp_path),
|
||||
patch("app.routers.debug._get_app_version", return_value="3.4.0"),
|
||||
patch("app.routers.debug._git_output", return_value=None),
|
||||
patch(
|
||||
"app.routers.debug.get_app_build_info",
|
||||
return_value=AppBuildInfo(
|
||||
version="3.4.0",
|
||||
version_source="pyproject",
|
||||
commit_hash=None,
|
||||
commit_source=None,
|
||||
),
|
||||
),
|
||||
patch("app.routers.debug.git_output", return_value=None),
|
||||
):
|
||||
info = debug_router._build_application_info()
|
||||
|
||||
assert info.version == "3.4.0"
|
||||
assert info.version_source == "pyproject"
|
||||
assert info.commit_hash is None
|
||||
assert info.commit_source is None
|
||||
assert info.git_branch is None
|
||||
assert info.git_dirty is False
|
||||
|
||||
|
||||
@@ -631,15 +631,18 @@ class TestCommunityMqttPublisher:
|
||||
|
||||
class TestPublishFailureSetsDisconnected:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_error_sets_connected_false(self):
|
||||
async def test_publish_error_sets_connected_false(self, caplog):
|
||||
"""A publish error should set connected=False so the loop can detect it."""
|
||||
pub = CommunityMqttPublisher()
|
||||
pub.set_integration_name("LetsMesh West")
|
||||
pub.connected = True
|
||||
mock_client = MagicMock()
|
||||
mock_client.publish = MagicMock(side_effect=Exception("broker gone"))
|
||||
pub._client = mock_client
|
||||
await pub.publish("topic", {"data": "test"})
|
||||
assert pub.connected is False
|
||||
assert "LetsMesh West" in caplog.text
|
||||
assert "if it self-resolves" in caplog.text
|
||||
|
||||
|
||||
class TestBuildStatusTopic:
|
||||
@@ -1217,21 +1220,13 @@ class TestGetClientVersion:
|
||||
result = _get_client_version()
|
||||
assert result.startswith("RemoteTerm ")
|
||||
|
||||
def test_returns_version_from_metadata(self):
|
||||
"""Should use importlib.metadata to get version."""
|
||||
with patch("app.fanout.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
|
||||
def test_returns_version_from_build_helper(self):
|
||||
"""Should use the shared backend build-info helper."""
|
||||
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
|
||||
mock_build_info.return_value.version = "1.2.3"
|
||||
result = _get_client_version()
|
||||
assert result == "RemoteTerm 1.2.3"
|
||||
|
||||
def test_fallback_on_error(self):
|
||||
"""Should return 'RemoteTerm unknown' if metadata lookup fails."""
|
||||
with patch(
|
||||
"app.fanout.community_mqtt.importlib.metadata.version",
|
||||
side_effect=Exception("not found"),
|
||||
):
|
||||
result = _get_client_version()
|
||||
assert result == "RemoteTerm unknown"
|
||||
|
||||
|
||||
class TestPublishStatus:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from app.routers.health import build_health_data
|
||||
from app.version_info import AppBuildInfo
|
||||
|
||||
|
||||
class TestHealthFanoutStatus:
|
||||
@@ -48,6 +49,15 @@ class TestHealthFanoutStatus:
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
),
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
patch(
|
||||
"app.routers.health.get_app_build_info",
|
||||
return_value=AppBuildInfo(
|
||||
version="3.4.1",
|
||||
version_source="pyproject",
|
||||
commit_hash="abcdef12",
|
||||
commit_source="git",
|
||||
),
|
||||
),
|
||||
):
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = True
|
||||
@@ -58,6 +68,10 @@ class TestHealthFanoutStatus:
|
||||
assert data["radio_initializing"] is False
|
||||
assert data["radio_state"] == "connected"
|
||||
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||
assert data["app_info"] == {
|
||||
"version": "3.4.1",
|
||||
"commit_hash": "abcdef12",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_cached_radio_device_info(self, test_db):
|
||||
|
||||
@@ -132,8 +132,9 @@ class TestMqttPublisher:
|
||||
assert call_args[1]["retain"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_handles_exception_gracefully(self):
|
||||
async def test_publish_handles_exception_gracefully(self, caplog):
|
||||
pub = MqttPublisher()
|
||||
pub.set_integration_name("Primary MQTT")
|
||||
pub.connected = True
|
||||
mock_client = AsyncMock()
|
||||
mock_client.publish.side_effect = Exception("Network error")
|
||||
@@ -145,6 +146,8 @@ class TestMqttPublisher:
|
||||
# After a publish failure, connected should be cleared to stop
|
||||
# further attempts and reflect accurate status
|
||||
assert pub.connected is False
|
||||
assert "Primary MQTT" in caplog.text
|
||||
assert "usually transient network noise" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_resets_state(self):
|
||||
|
||||
41
tests/test_version_info.py
Normal file
41
tests/test_version_info.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from app import version_info
|
||||
|
||||
|
||||
class TestAppBuildInfo:
|
||||
def setup_method(self):
|
||||
version_info.get_app_build_info.cache_clear()
|
||||
|
||||
def teardown_method(self):
|
||||
version_info.get_app_build_info.cache_clear()
|
||||
|
||||
def test_prefers_package_metadata_and_git(self, monkeypatch):
|
||||
monkeypatch.setattr(version_info, "_package_metadata_version", lambda: "3.4.1")
|
||||
monkeypatch.setattr(version_info, "_env_version", lambda: "3.4.0-env")
|
||||
monkeypatch.setattr(version_info, "_build_info_version", lambda build_info: "3.3.0-build")
|
||||
monkeypatch.setattr(version_info, "_pyproject_version", lambda root: "3.2.0-pyproject")
|
||||
monkeypatch.setattr(version_info, "_git_output", lambda root, *args: "abcdef12")
|
||||
monkeypatch.setattr(version_info, "_env_commit_hash", lambda: "fedcba0987654321")
|
||||
monkeypatch.setattr(version_info, "_build_info_commit_hash", lambda build_info: "11223344")
|
||||
|
||||
info = version_info.get_app_build_info()
|
||||
|
||||
assert info.version == "3.4.1"
|
||||
assert info.version_source == "package_metadata"
|
||||
assert info.commit_hash == "abcdef12"
|
||||
assert info.commit_source == "git"
|
||||
|
||||
def test_falls_back_to_pyproject_and_build_info(self, monkeypatch):
|
||||
monkeypatch.setattr(version_info, "_package_metadata_version", lambda: None)
|
||||
monkeypatch.setattr(version_info, "_env_version", lambda: None)
|
||||
monkeypatch.setattr(version_info, "_build_info_version", lambda build_info: None)
|
||||
monkeypatch.setattr(version_info, "_pyproject_version", lambda root: "3.2.0")
|
||||
monkeypatch.setattr(version_info, "_git_output", lambda root, *args: None)
|
||||
monkeypatch.setattr(version_info, "_env_commit_hash", lambda: None)
|
||||
monkeypatch.setattr(version_info, "_build_info_commit_hash", lambda build_info: "cf1a55e2")
|
||||
|
||||
info = version_info.get_app_build_info()
|
||||
|
||||
assert info.version == "3.2.0"
|
||||
assert info.version_source == "pyproject"
|
||||
assert info.commit_hash == "cf1a55e2"
|
||||
assert info.commit_source == "build_info"
|
||||
Reference in New Issue
Block a user