From db248302e977ae9a0f35e55e13c5603cb7b208a2 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 12:28:26 -0700 Subject: [PATCH 01/23] Show node name if we find it in the DB already. Closes #128. --- app/models.py | 4 ++++ app/routers/radio.py | 16 +++++++++++++ .../settings/SettingsRadioSection.tsx | 7 +++++- frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/types.ts | 1 + tests/test_radio_router.py | 23 +++++++++++++++++-- 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/models.py b/app/models.py index cf91209..0d97019 100644 --- a/app/models.py +++ b/app/models.py @@ -681,6 +681,10 @@ class RadioDiscoveryResult(BaseModel): """One mesh node heard during a discovery sweep.""" public_key: str = Field(description="Discovered node public key as hex") + name: str | None = Field( + default=None, + description="Known name for this node from contacts DB, if any", + ) node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class") heard_count: int = Field(default=1, description="How many responses were heard from this node") local_snr: float | None = Field( diff --git a/app/routers/radio.py b/app/routers/radio.py index 795c106..1d8d3f8 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -18,6 +18,7 @@ from app.models import ( from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time from app.repository import ContactRepository +from app.services.contact_reconciliation import promote_prefix_contacts_for_contact from app.services.radio_commands import ( KeystoreRefreshError, PathHashModeUnsupportedError, @@ -197,9 +198,23 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) - on_radio=False, ) await ContactRepository.upsert(contact) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=result.public_key, + log=logger, + ) created = await ContactRepository.get_by_key(result.public_key) if created is not None: broadcast_event("contact", created.model_dump()) + for old_key in promoted_keys: + broadcast_event("contact_deleted", {"public_key": old_key}) + + +async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None: + """Resolve known contact names for discovery results from the DB.""" + for result in results: + contact = await ContactRepository.get_by_key(result.public_key) + if contact is not None and contact.name: + result.name = contact.name @router.get("/config", response_model=RadioConfigResponse) @@ -365,6 +380,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons ), ) await _persist_new_discovery_contacts(results) + await _attach_known_names(results) return RadioDiscoveryResponse( target=request.target, duration_seconds=DISCOVERY_WINDOW_SECONDS, diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 4b3f68e..58611e4 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -846,11 +846,16 @@ export function SettingsRadioSection({ className="rounded-md border border-input bg-background px-3 py-2" >
- {result.node_type} + + {result.name ?? {result.node_type}} + heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
+ {result.name && ( +

{result.node_type}

+ )}

{result.public_key}

diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 7f6c1a1..aa96825 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -300,6 +300,7 @@ describe('SettingsModal', () => { results: [ { public_key: '11'.repeat(32), + name: null, node_type: 'repeater', heard_count: 2, local_snr: 7.5, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 834ed32..2f1d7e0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all'; export interface RadioDiscoveryResult { public_key: string; + name: string | null; node_type: 'repeater' | 'sensor'; heard_count: number; local_snr: number | null; diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index c4acde8..b7eba08 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -2,7 +2,7 @@ import asyncio from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException @@ -375,6 +375,11 @@ class TestDiscoverMesh: return_value=None, ), patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock), + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ), patch("app.routers.radio.broadcast_event"), ): response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) @@ -436,18 +441,27 @@ class TestDiscoverMesh: patch( "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock, - side_effect=[None, created_contact], + # 1st: _persist check (not found), 2nd: _persist re-fetch (created), + # 3rd: _attach_known_names lookup + side_effect=[None, created_contact, created_contact], ) as mock_get_by_key, patch( "app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock ) as mock_upsert, + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ) as mock_promote, patch("app.routers.radio.broadcast_event") as mock_broadcast, ): response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) assert len(response.results) == 1 + assert response.results[0].name is None # created_contact has no name mock_get_by_key.assert_awaited() mock_upsert.assert_awaited_once() + mock_promote.assert_awaited_once_with(public_key="44" * 32, log=ANY) upsert_arg = mock_upsert.await_args.args[0] assert upsert_arg.public_key == "44" * 32 assert upsert_arg.type == 2 @@ -542,6 +556,11 @@ class TestDiscoverMesh: return_value=None, ), patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock), + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ), patch("app.routers.radio.broadcast_event"), ): response = await discover_mesh(RadioDiscoveryRequest(target="all")) From d4bbb8a5422079cad8ca6cff68098c6ccb00a11c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 12:52:01 -0700 Subject: [PATCH 02/23] Add multibyte trace output. Closes #127. --- app/routers/contacts.py | 20 ++++++++++++++------ frontend/src/components/ChatHeader.tsx | 2 +- frontend/src/components/PathModal.tsx | 7 ++++--- tests/test_repeater_routes.py | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index a4289df..3e628b8 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -40,6 +40,10 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/contacts", tags=["contacts"]) +TRACE_HASH_BYTES = 4 +TRACE_FLAGS_4BYTE = 2 + + def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str: sample = ", ".join(key[:12] for key in err.matches[:2]) return ( @@ -373,17 +377,17 @@ async def delete_contact(public_key: str) -> dict: async def request_trace(public_key: str) -> TraceResponse: """Send a single-hop trace to a contact and wait for the result. - The trace path contains the contact's 1-byte pubkey hash as the sole hop - (no intermediate repeaters). The radio firmware requires at least one - node in the path. + The trace path contains the contact's 4-byte pubkey hash as the sole hop + (no intermediate repeaters). This uses TRACE's dedicated width flags rather + than the radio's normal path_hash_mode setting. """ require_connected() contact = await _resolve_contact_or_404(public_key) tag = random.randint(1, 0xFFFFFFFF) - # First 2 hex chars of pubkey = 1-byte hash used by the trace protocol - contact_hash = contact.public_key[:2] + # Use a 4-byte contact hash for low-collision direct trace targeting. + contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2] # Trace does not need auto-fetch suspension: response arrives as TRACE_DATA # from the reader loop, not via get_msg(). @@ -394,7 +398,11 @@ async def request_trace(public_key: str) -> TraceResponse: logger.info( "Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash ) - result = await mc.commands.send_trace(path=contact_hash, tag=tag) + result = await mc.commands.send_trace( + path=contact_hash, + tag=tag, + flags=TRACE_FLAGS_4BYTE, + ) if result.type == EventType.ERROR: raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}") diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index d92c4b3..aa6fba9 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -268,7 +268,7 @@ export function ChatHeader({ title={ activeContactIsPrefixOnly ? 'Direct Trace unavailable until the full contact key is known' - : 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR' + : 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR' } aria-label="Direct Trace" disabled={activeContactIsPrefixOnly} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index aef5ba1..61d30d3 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -81,13 +81,14 @@ export function PathModal({ ) : hasSinglePath ? ( <> This shows one route that this message traveled through the mesh network. - Repeaters may be incorrectly identified due to prefix collisions between heard and - non-heard repeater advertisements. + Repeater identities are inferred from locally known advert and path data, so some + hops may be missing or misidentified when that data is incomplete. ) : ( <> This message was received via {paths.length} different routes. - Repeaters may be incorrectly identified due to prefix collisions. + Repeater identities are inferred from locally known advert and path data, so some + hops may be missing or misidentified when that data is incomplete. )} diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index 7f113fb..8d27f64 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -483,6 +483,11 @@ class TestTraceRoute: await request_trace(KEY_A) assert exc.value.status_code == 500 + mc.commands.send_trace.assert_awaited_once_with( + path=KEY_A[:8], + tag=1234, + flags=2, + ) @pytest.mark.asyncio async def test_wait_timeout_returns_504(self, test_db): @@ -500,6 +505,11 @@ class TestTraceRoute: await request_trace(KEY_A) assert exc.value.status_code == 504 + mc.commands.send_trace.assert_awaited_once_with( + path=KEY_A[:8], + tag=1234, + flags=2, + ) @pytest.mark.asyncio async def test_success_returns_remote_and_local_snr(self, test_db): @@ -520,6 +530,11 @@ class TestTraceRoute: assert response.remote_snr == 5.5 assert response.local_snr == 3.2 assert response.path_len == 2 + mc.commands.send_trace.assert_awaited_once_with( + path=KEY_A[:8], + tag=1234, + flags=2, + ) # --------------------------------------------------------------------------- From b42ca44ba7aa3f06bd28b6f102ffd4616a89bc56 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 14:23:01 -0700 Subject: [PATCH 03/23] Add noise floor plumbing --- app/main.py | 3 + app/models.py | 22 ++++++ app/routers/statistics.py | 2 + app/services/radio_noise_floor.py | 112 ++++++++++++++++++++++++++++++ tests/test_statistics.py | 27 +++++++ 5 files changed, 166 insertions(+) create mode 100644 app/services/radio_noise_floor.py diff --git a/app/main.py b/app/main.py index ddbe0bc..286cf95 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.routers import ( ws, ) from app.security import add_optional_basic_auth_middleware +from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling from app.services.radio_runtime import radio_runtime as radio_manager from app.version_info import get_app_build_info @@ -70,6 +71,7 @@ async def lifespan(app: FastAPI): from app.radio_sync import ensure_default_channels await ensure_default_channels() + await start_noise_floor_sampling() # Always start connection monitor (even if initial connection failed) await radio_manager.start_connection_monitor() @@ -98,6 +100,7 @@ async def lifespan(app: FastAPI): await radio_manager.stop_connection_monitor() await stop_background_contact_reconciliation() await stop_message_polling() + await stop_noise_floor_sampling() await stop_periodic_advert() await stop_periodic_sync() if radio_manager.meshcore: diff --git a/app/models.py b/app/models.py index 0d97019..010c20a 100644 --- a/app/models.py +++ b/app/models.py @@ -824,6 +824,27 @@ class PathHashWidthStats(BaseModel): triple_byte_pct: float +class NoiseFloorSample(BaseModel): + timestamp: int = Field(description="Unix timestamp of the sampled reading") + noise_floor_dbm: int = Field(description="Noise floor in dBm") + + +class NoiseFloorHistoryStats(BaseModel): + sample_interval_seconds: int = Field(description="Expected spacing between samples") + coverage_seconds: int = Field(description="How much of the last 24 hours is represented") + latest_noise_floor_dbm: int | None = Field( + default=None, description="Most recent sampled noise floor in dBm" + ) + latest_timestamp: int | None = Field( + default=None, description="Unix timestamp of the most recent sample" + ) + supported: bool | None = Field( + default=None, + description="Whether the connected radio appears to support radio stats sampling", + ) + samples: list[NoiseFloorSample] = Field(default_factory=list) + + class StatisticsResponse(BaseModel): busiest_channels_24h: list[BusyChannel] contact_count: int @@ -839,3 +860,4 @@ class StatisticsResponse(BaseModel): repeaters_heard: ContactActivityCounts known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats + noise_floor_24h: NoiseFloorHistoryStats diff --git a/app/routers/statistics.py b/app/routers/statistics.py index 00dcbc8..a8050c8 100644 --- a/app/routers/statistics.py +++ b/app/routers/statistics.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from app.models import StatisticsResponse from app.repository import StatisticsRepository +from app.services.radio_noise_floor import get_noise_floor_history router = APIRouter(prefix="/statistics", tags=["statistics"]) @@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"]) @router.get("", response_model=StatisticsResponse) async def get_statistics() -> StatisticsResponse: data = await StatisticsRepository.get_all() + data["noise_floor_24h"] = await get_noise_floor_history() return StatisticsResponse(**data) diff --git a/app/services/radio_noise_floor.py b/app/services/radio_noise_floor.py new file mode 100644 index 0000000..7790f49 --- /dev/null +++ b/app/services/radio_noise_floor.py @@ -0,0 +1,112 @@ +"""In-memory local-radio noise floor history sampling.""" + +import asyncio +import logging +import time +from collections import deque + +from meshcore import EventType + +from app.radio import RadioDisconnectedError, RadioOperationBusyError +from app.services.radio_runtime import radio_runtime as radio_manager + +logger = logging.getLogger(__name__) + +NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300 +NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60 +MAX_NOISE_FLOOR_SAMPLES = 300 + +_noise_floor_task: asyncio.Task | None = None +_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES) +_noise_floor_supported: bool | None = None +_samples_lock = asyncio.Lock() + + +async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None: + async with _samples_lock: + _noise_floor_samples.append((timestamp, noise_floor_dbm)) + + +async def sample_noise_floor_once(*, blocking: bool = False) -> None: + """Fetch the current radio noise floor once and record it when available.""" + global _noise_floor_supported + + if not radio_manager.is_connected: + return + + try: + async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc: + event = await mc.commands.get_stats_radio() + except (RadioDisconnectedError, RadioOperationBusyError): + return + except Exception as exc: + logger.debug("Noise floor sampling failed: %s", exc) + return + + if event.type == EventType.ERROR: + _noise_floor_supported = False + return + + if event.type != EventType.STATS_RADIO: + return + + noise_floor = event.payload.get("noise_floor") + if not isinstance(noise_floor, int): + return + + _noise_floor_supported = True + await _append_sample(int(time.time()), noise_floor) + + +async def _noise_floor_sampling_loop() -> None: + while True: + await sample_noise_floor_once() + await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS) + + +async def start_noise_floor_sampling() -> None: + global _noise_floor_task + if _noise_floor_task is not None and not _noise_floor_task.done(): + return + _noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop()) + + +async def stop_noise_floor_sampling() -> None: + global _noise_floor_task + if _noise_floor_task is None: + return + if not _noise_floor_task.done(): + _noise_floor_task.cancel() + try: + await _noise_floor_task + except asyncio.CancelledError: + pass + _noise_floor_task = None + + +async def get_noise_floor_history() -> dict: + """Return the current 24-hour in-memory noise floor history snapshot.""" + await sample_noise_floor_once(blocking=False) + + now = int(time.time()) + cutoff = now - NOISE_FLOOR_WINDOW_SECONDS + + async with _samples_lock: + samples = [ + {"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm} + for timestamp, noise_floor_dbm in _noise_floor_samples + if timestamp >= cutoff + ] + + latest = samples[-1] if samples else None + oldest_timestamp = samples[0]["timestamp"] if samples else None + coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp) + + return { + "sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS, + "coverage_seconds": coverage_seconds, + "latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None, + "latest_timestamp": latest["timestamp"] if latest else None, + "supported": _noise_floor_supported, + "samples": samples, + } diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 66b8748..6a50531 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -1,6 +1,7 @@ """Tests for the statistics repository and endpoint.""" import time +from unittest.mock import AsyncMock, patch import pytest @@ -347,3 +348,29 @@ class TestPathHashWidthStats: assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) + + +class TestStatisticsEndpoint: + @pytest.mark.asyncio + async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client): + noise_floor_history = { + "sample_interval_seconds": 300, + "coverage_seconds": 1800, + "latest_noise_floor_dbm": -119, + "latest_timestamp": 1_700_000_000, + "supported": True, + "samples": [ + {"timestamp": 1_699_998_200, "noise_floor_dbm": -121}, + {"timestamp": 1_700_000_000, "noise_floor_dbm": -119}, + ], + } + + with patch( + "app.routers.statistics.get_noise_floor_history", + new=AsyncMock(return_value=noise_floor_history), + ): + response = await client.get("/api/statistics") + + assert response.status_code == 200 + payload = response.json() + assert payload["noise_floor_24h"] == noise_floor_history From 60f3fa8e36001d03dbce3760d1dfaa52b005a208 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 15:26:54 -0700 Subject: [PATCH 04/23] Add noise floor visualizer to statistics. Closes #129. --- app/services/radio_noise_floor.py | 2 - frontend/package-lock.json | 387 +++++++++++++++++- frontend/package.json | 1 + frontend/src/components/ContactInfoPane.tsx | 232 +++++------ frontend/src/components/RawPacketFeedView.tsx | 164 +++++--- .../settings/SettingsStatisticsSection.tsx | 325 ++++++++++++--- frontend/src/test/settingsModal.test.tsx | 26 +- frontend/src/types.ts | 15 + 8 files changed, 894 insertions(+), 258 deletions(-) diff --git a/app/services/radio_noise_floor.py b/app/services/radio_noise_floor.py index 7790f49..d928821 100644 --- a/app/services/radio_noise_floor.py +++ b/app/services/radio_noise_floor.py @@ -86,8 +86,6 @@ async def stop_noise_floor_sampling() -> None: async def get_noise_floor_history() -> dict: """Return the current 24-hour in-memory noise floor history snapshot.""" - await sample_noise_floor_once(blocking=False) - now = int(time.time()) cutoff = now - NOISE_FLOOR_WINDOW_SECONDS diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 82e416e..69c4495 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.6.1", + "version": "3.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.6.1", + "version": "3.6.2", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", @@ -30,6 +30,7 @@ "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", "react-swipeable": "^7.0.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -2057,6 +2058,42 @@ "react-dom": "^18.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2414,6 +2451,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2564,6 +2613,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, "node_modules/@types/d3-force": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", @@ -2571,6 +2638,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2663,6 +2775,12 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -3712,12 +3830,33 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-binarytree": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", @@ -3727,6 +3866,15 @@ "node": ">=12" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", @@ -3757,12 +3905,42 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-octree": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", "license": "MIT" }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", @@ -3772,6 +3950,58 @@ "node": ">=12" } }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -3820,6 +4050,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3974,6 +4210,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4216,6 +4462,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4618,6 +4870,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4655,6 +4917,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5599,7 +5870,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, @@ -5617,6 +5887,29 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5726,6 +6019,36 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5740,6 +6063,27 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6134,6 +6478,12 @@ "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6448,12 +6798,43 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3d91d98..36f6053 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", "react-swipeable": "^7.0.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 1daea1d..59587ce 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -1,5 +1,15 @@ import { type ReactNode, useEffect, useState } from 'react'; import { Ban, Search, Star } from 'lucide-react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { @@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu {hasHourlyActivity && (
Messages Per Hour - value.toFixed(value % 1 === 0 ? 0 : 1)} tickFormatter={(bucket) => @@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu value.toFixed(0)} tickFormatter={(bucket) => new Date(bucket.bucket_start * 1000).toLocaleDateString([], { @@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu ); } -function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) { - return ( -
- {items.map((item) => ( - - - ))} -
- ); -} +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; function ActivityLineChart({ ariaLabel, points, series, + legendItems, tickFormatter, valueFormatter, }: { ariaLabel: string; points: T[]; - series: Array<{ key: keyof T; color: string }>; + series: Array<{ key: keyof T; color: string; label?: string }>; + legendItems?: Array<{ label: string; color: string }>; tickFormatter: (point: T) => string; valueFormatter: (value: number) => string; }) { - const width = 320; - const height = 132; - const padding = { top: 8, right: 8, bottom: 24, left: 32 }; - const plotWidth = width - padding.left - padding.right; - const plotHeight = height - padding.top - padding.bottom; - const allValues = points.flatMap((point) => - series.map((entry) => { - const value = point[entry.key]; - return typeof value === 'number' ? value : 0; - }) - ); - const maxValue = Math.max(1, ...allValues); - const tickIndices = Array.from( - new Set([ - 0, - Math.floor((points.length - 1) / 3), - Math.floor(((points.length - 1) * 2) / 3), - points.length - 1, - ]) - ); + const data = points.map((point, i) => { + const entry: Record = { idx: i, tick: tickFormatter(point) }; + for (const s of series) { + const raw = point[s.key]; + entry[String(s.key)] = typeof raw === 'number' ? raw : 0; + } + return entry; + }); - const buildPolyline = (key: keyof T) => - points - .map((point, index) => { - const rawValue = point[key]; - const value = typeof rawValue === 'number' ? rawValue : 0; - const x = - padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); - const y = padding.top + plotHeight - (value / maxValue) * plotHeight; - return `${x},${y}`; - }) - .join(' '); + const tickCount = Math.min(5, points.length); + const tickIndices: number[] = []; + if (points.length > 1) { + for (let i = 0; i < tickCount; i++) { + tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1))); + } + } return ( -
- - {[0, 0.5, 1].map((ratio) => { - const y = padding.top + plotHeight - ratio * plotHeight; - const value = maxValue * ratio; - return ( - - - - {valueFormatter(value)} - - - ); - })} - - {series.map((entry) => ( - + + + + String(data[idx]?.tick ?? '')} /> - ))} - - {tickIndices.map((index) => { - const point = points[index]; - const x = - padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); - return ( - - {tickFormatter(point)} - - ); - })} - + valueFormatter(v)} + width={40} + /> + String(data[Number(idx)]?.tick ?? '')} + formatter={(value, name) => { + const match = series.find((s) => String(s.key) === name); + return [valueFormatter(Number(value)), match?.label ?? String(name)]; + }} + /> + {legendItems && ( + ( +
+ {legendItems.map((item) => ( + + + {item.label} + + ))} +
+ )} + /> + )} + {series.map((entry) => ( + + ))} + +
); } diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 50f4407..6b04df4 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -1,5 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; import { RawPacketList } from './RawPacketList'; import { RawPacketInspectorDialog } from './RawPacketDetailModal'; @@ -24,6 +34,18 @@ interface RawPacketFeedViewProps { channels: Channel[]; } +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; + const WINDOW_LABELS: Record = { '1m': '1 min', '5m': '5 min', @@ -32,13 +54,7 @@ const WINDOW_LABELS: Record = { session: 'Session', }; -const TIMELINE_COLORS = [ - 'bg-sky-500/80', - 'bg-emerald-500/80', - 'bg-amber-500/80', - 'bg-rose-500/80', - 'bg-violet-500/80', -]; +const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6']; function formatTimestamp(timestampMs: number): string { return new Date(timestampMs).toLocaleString([], { @@ -220,7 +236,13 @@ function RankedBars({ emptyLabel: string; formatter?: (item: RankedPacketStat) => string; }) { - const maxCount = Math.max(...items.map((item) => item.count), 1); + const data = items.map((item) => ({ + name: item.label, + value: item.count, + detail: formatter + ? formatter(item) + : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`, + })); return (
@@ -228,25 +250,36 @@ function RankedBars({ {items.length === 0 ? (

{emptyLabel}

) : ( -
- {items.map((item) => ( -
-
- {item.label} - - {formatter - ? formatter(item) - : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`} - -
-
-
-
-
- ))} +
+ + + + + [props.payload.detail, null]} + /> + + {data.map((_, i) => ( + + ))} + + +
)}
@@ -320,53 +353,66 @@ function NeighborList({ } function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) { - const maxTotal = Math.max(...bins.map((bin) => bin.total), 1); const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice( 0, - TIMELINE_COLORS.length + TIMELINE_FILL_COLORS.length ); + const data = bins.map((bin) => { + const entry: Record = { label: bin.label }; + for (const type of typeOrder) { + entry[type] = bin.countsByType[type] ?? 0; + } + return entry; + }); + return (

Traffic Timeline

- {typeOrder.map((type, index) => ( + {typeOrder.map((type, i) => ( - + {type} ))}
- -
- {bins.map((bin, index) => ( -
-
-
- {typeOrder.map((type, index) => { - const count = bin.countsByType[type] ?? 0; - if (count === 0) return null; - return ( -
- ); - })} -
-
-
{bin.label}
-
- ))} +
+ + + + + + + {typeOrder.map((type, i) => ( + + ))} + +
); diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 626088a..6605a03 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -1,4 +1,16 @@ import { useState, useEffect } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + AreaChart, + Area, + Cell, +} from 'recharts'; import { Separator } from '../ui/separator'; import { api } from '../../api'; import type { StatisticsResponse } from '../../types'; @@ -7,6 +19,94 @@ function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } +const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6']; + +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function NoiseFloorChart({ + samples, +}: { + samples: { timestamp: number; noise_floor_dbm: number }[]; +}) { + const data = samples.map((s, i) => ({ + idx: i, + time: formatTime(s.timestamp), + noise_floor: s.noise_floor_dbm, + })); + + const tickCount = Math.min(6, samples.length); + const tickIndices: number[] = []; + if (samples.length > 1) { + for (let i = 0; i < tickCount; i++) { + tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1))); + } + } + + return ( + + + + data[idx]?.time ?? ''} + /> + `${v}`} + /> + data[Number(idx)]?.time ?? ''} + formatter={(value) => [`${value} dBm`, 'Noise Floor']} + /> + + + + ); +} + export function SettingsStatisticsSection({ className }: { className?: string }) { const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); @@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string }) - {/* Packets */} -
-

Packets

-
-
- Total stored - {stats.total_packets} -
-
- Decrypted - {stats.decrypted_packets} -
-
- Undecrypted - {stats.undecrypted_packets} -
-
-
- - - -
-

Path Hash Width (24h)

-
- Parsed stored raw packets from the last 24 hours:{' '} - {stats.path_hash_width_24h.total_packets} -
-
-
- 1-byte hops - - {stats.path_hash_width_24h.single_byte} ( - {formatPercent(stats.path_hash_width_24h.single_byte_pct)}) - -
-
- 2-byte hops - - {stats.path_hash_width_24h.double_byte} ( - {formatPercent(stats.path_hash_width_24h.double_byte_pct)}) - -
-
- 3-byte hops - - {stats.path_hash_width_24h.triple_byte} ( - {formatPercent(stats.path_hash_width_24h.triple_byte_pct)}) - -
-
-
- - - {/* Activity */}

Activity

@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
+ + + {/* Packets */} +
+

Packets

+
+
+ Total stored + {stats.total_packets} +
+
+ Decrypted + {stats.decrypted_packets} +
+
+ Undecrypted + {stats.undecrypted_packets} +
+
+
+ + + + {/* Path Hash Width */} +
+

Path Hash Width (24h)

+
+ Parsed stored raw packets from the last 24 hours:{' '} + {stats.path_hash_width_24h.total_packets} +
+ {stats.path_hash_width_24h.total_packets > 0 ? ( + + + + + + [ + `${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`, + 'Packets', + ]} + /> + + + + + + + + ) : ( +

No path data in the last 24 hours.

+ )} +
+ {/* Busiest Channels */} {stats.busiest_channels_24h.length > 0 && ( <>

Busiest Channels (24h)

-
- {stats.busiest_channels_24h.map((ch, i) => ( -
- - {i + 1}. - {ch.channel_name} - - {ch.message_count} msgs -
- ))} -
+ + ({ + name: ch.channel_name, + messages: ch.message_count, + }))} + layout="vertical" + margin={{ top: 0, right: 4, bottom: 0, left: 0 }} + barCategoryGap="20%" + > + + + [`${Number(value).toLocaleString()} messages`, null]} + /> + + {stats.busiest_channels_24h.map((_, i) => ( + + ))} + + + +
+ + )} + + {/* Noise Floor */} + {stats.noise_floor_24h.supported !== false && ( + <> + +
+

Noise Floor (24h)

+ {stats.noise_floor_24h.latest_noise_floor_dbm != null && ( +
+ Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm + {stats.noise_floor_24h.latest_timestamp != null && + ` at ${new Date( + stats.noise_floor_24h.latest_timestamp * 1000 + ).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })}`} +
+ )} + {stats.noise_floor_24h.samples.length > 1 ? ( + + ) : stats.noise_floor_24h.samples.length === 0 ? ( +

+ No noise floor samples collected yet. Samples are collected every five minutes, + and retained until server restart. +

+ ) : ( +

+ Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm). + More data needed for a chart. Samples are collected every five minutes, and + retained until server restart. +

+ )}
)} diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index aa96825..ec6d8f1 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -595,6 +595,14 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + noise_floor_24h: { + sample_interval_seconds: 300, + coverage_seconds: 3600, + latest_noise_floor_dbm: -105, + latest_timestamp: 1711800000, + supported: true, + samples: [], + }, }; vi.spyOn(globalThis, 'fetch').mockResolvedValue( @@ -626,17 +634,11 @@ describe('SettingsModal', () => { expect( screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/) ).toBeInTheDocument(); - expect(screen.getByText('1-byte hops')).toBeInTheDocument(); - expect(screen.getByText('60 (50.0%)')).toBeInTheDocument(); - expect(screen.getByText('36 (30.0%)')).toBeInTheDocument(); - expect(screen.getByText('24 (20.0%)')).toBeInTheDocument(); expect(screen.getByText('Contacts heard')).toBeInTheDocument(); expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); expect(screen.getByText('Known-channels active')).toBeInTheDocument(); - - // Busiest channels - expect(screen.getByText('general')).toBeInTheDocument(); - expect(screen.getByText('42 msgs')).toBeInTheDocument(); + expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument(); + expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument(); }); it('fetches statistics when expanded in mobile external-nav mode', async () => { @@ -663,6 +665,14 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + noise_floor_24h: { + sample_interval_seconds: 300, + coverage_seconds: 0, + latest_noise_floor_dbm: null, + latest_timestamp: null, + supported: null, + samples: [], + }, }; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2f1d7e0..d54a573 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -505,6 +505,20 @@ interface ContactActivityCounts { last_week: number; } +export interface NoiseFloorSample { + timestamp: number; + noise_floor_dbm: number; +} + +export interface NoiseFloorHistoryStats { + sample_interval_seconds: number; + coverage_seconds: number; + latest_noise_floor_dbm: number | null; + latest_timestamp: number | null; + supported: boolean | null; + samples: NoiseFloorSample[]; +} + export interface StatisticsResponse { busiest_channels_24h: BusyChannel[]; contact_count: number; @@ -528,4 +542,5 @@ export interface StatisticsResponse { double_byte_pct: number; triple_byte_pct: number; }; + noise_floor_24h: NoiseFloorHistoryStats; } From 3f6efaae1dde068422c03eb8c06a03cfb63807d0 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 15:40:13 -0700 Subject: [PATCH 05/23] Overhaul script handling. Closes #125. --- .gitignore | 2 + AGENTS.md | 18 ++-- CONTRIBUTING.md | 2 +- LICENSES.md | 3 +- README.md | 4 +- README_ADVANCED.md | 4 +- app/database.py | 4 + app/migrations.py | 41 +++++++++ frontend/AGENTS.md | 2 +- scripts/{ => build}/collect_licenses.sh | 10 +- .../{ => build}/print_frontend_licenses.cjs | 0 scripts/{ => build}/publish.sh | 28 +++--- scripts/{ => quality}/all_quality.sh | 12 +-- scripts/{ => quality}/docker_ci.sh | 6 +- scripts/{ => quality}/e2e.sh | 4 +- scripts/{ => quality}/extended_quality.sh | 8 +- .../{ => setup}/fetch_prebuilt_frontend.py | 3 +- scripts/{ => setup}/install_service.sh | 8 +- tests/test_migrations.py | 92 ++++++++++++++++--- 19 files changed, 184 insertions(+), 67 deletions(-) rename scripts/{ => build}/collect_licenses.sh (89%) mode change 100755 => 100644 rename scripts/{ => build}/print_frontend_licenses.cjs (100%) mode change 100755 => 100644 rename scripts/{ => build}/publish.sh (92%) mode change 100755 => 100644 rename scripts/{ => quality}/all_quality.sh (90%) mode change 100755 => 100644 rename scripts/{ => quality}/docker_ci.sh (95%) mode change 100755 => 100644 rename scripts/{ => quality}/e2e.sh (52%) mode change 100755 => 100644 rename scripts/{ => quality}/extended_quality.sh (78%) mode change 100755 => 100644 rename scripts/{ => setup}/fetch_prebuilt_frontend.py (97%) mode change 100755 => 100644 rename scripts/{ => setup}/install_service.sh (98%) mode change 100755 => 100644 diff --git a/.gitignore b/.gitignore index 9ed39af..16f3c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ __pycache__/ *.py[oc] build/ +!scripts/build/ +!scripts/build/** wheels/ *.egg-info diff --git a/AGENTS.md b/AGENTS.md index f6debc9..07d1c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run: ```bash -./scripts/all_quality.sh +./scripts/quality/all_quality.sh ``` This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind. @@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ │ └── ... │ └── vite.config.ts ├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive) -│ ├── all_quality.sh # Repo-standard autofix + validate gate -│ ├── collect_licenses.sh # Gather third-party license attributions -│ ├── e2e.sh # End-to-end test runner -│ └── publish.sh # Version bump, changelog, docker build & push +│ ├── build/ +│ │ ├── collect_licenses.sh # Gather third-party license attributions +│ │ └── publish.sh # Version bump, changelog, docker build & push +│ ├── quality/ +│ │ ├── all_quality.sh # Repo-standard autofix + validate gate +│ │ ├── e2e.sh # End-to-end test runner +│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix +│ └── setup/ +│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback +│ └── install_service.sh # Install/configure Linux systemd service ├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance ├── CONTRIBUTING.md # Contributor workflow and testing guidance ├── tests/ # Backend tests (pytest) @@ -298,7 +304,7 @@ npm run test:run ### Before Completing Major Changes -**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate. +**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate. ## API Summary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb76d5e..e54d449 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development. Run the full quality suite before proposing or handing off code changes: ```bash -./scripts/all_quality.sh +./scripts/quality/all_quality.sh ``` That runs linting, formatting, type checking, tests, and builds for both backend and frontend. diff --git a/LICENSES.md b/LICENSES.md index 7fac22f..525a5aa 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -1,6 +1,6 @@ # Third-Party Licenses -Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand. +Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand. ## Backend (Python) Dependencies @@ -1748,4 +1748,3 @@ THE SOFTWARE. ``` - diff --git a/README.md b/README.md index a20e334..29b9709 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Access the app at http://localhost:8000. Source checkouts expect a normal frontend build in `frontend/dist`. -On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root. +On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/setup/install_service.sh` from the repo root. ## Path 1.5: Use The Prebuilt Release Zip @@ -111,7 +111,7 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build. -Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`. +Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/setup/fetch_prebuilt_frontend.py`. ## Path 2: Docker diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 2b0684c..c61ed54 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -53,7 +53,7 @@ Two paths are available depending on your comfort level with Linux system admini On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required. ```bash -bash scripts/install_service.sh +bash scripts/setup/install_service.sh ``` The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics: @@ -69,7 +69,7 @@ cd frontend && npm install && npm run build && cd .. sudo systemctl restart remoteterm # Refresh prebuilt frontend only (skips local build) -python3 scripts/fetch_prebuilt_frontend.py +python3 scripts/setup/fetch_prebuilt_frontend.py sudo systemctl restart remoteterm # View live logs diff --git a/app/database.py b/app/database.py index e4cb912..77f8897 100644 --- a/app/database.py +++ b/app/database.py @@ -96,8 +96,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0)) WHERE type = 'CHAN'; CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id); +CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp); CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash); CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio); +CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen); +CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation + ON messages(type, received_at, conversation_key); -- idx_messages_sender_key is created by migration 25 (after adding the sender_key column) -- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent diff --git a/app/migrations.py b/app/migrations.py index 64c7826..f7048b9 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 46) applied += 1 + # Migration 47: Add statistics indexes for time-windowed scans + if version < 47: + logger.info("Applying migration 47: add statistics indexes") + await _migrate_047_add_statistics_indexes(conn) + await set_version(conn, 47) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -2868,3 +2875,37 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne ) await conn.commit() + + +async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None: + """Add indexes used by the statistics endpoint's time-windowed scans.""" + cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in await cursor.fetchall()} + + if "raw_packets" in tables: + cursor = await conn.execute("PRAGMA table_info(raw_packets)") + raw_packet_columns = {row[1] for row in await cursor.fetchall()} + if "timestamp" in raw_packet_columns: + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)" + ) + + if "contacts" in tables: + cursor = await conn.execute("PRAGMA table_info(contacts)") + contact_columns = {row[1] for row in await cursor.fetchall()} + if {"type", "last_seen"}.issubset(contact_columns): + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)" + ) + + if "messages" in tables: + cursor = await conn.execute("PRAGMA table_info(messages)") + message_columns = {row[1] for row in await cursor.fetchall()} + if {"type", "received_at", "conversation_key"}.issubset(message_columns): + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation + ON messages(type, received_at, conversation_key) + """ + ) + await conn.commit() diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ac50444..8e23a94 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -404,7 +404,7 @@ Do not rely on old class-only layout assumptions. Run all quality checks (backend + frontend) from the repo root: ```bash -./scripts/all_quality.sh +./scripts/quality/all_quality.sh ``` Or run frontend checks individually: diff --git a/scripts/collect_licenses.sh b/scripts/build/collect_licenses.sh old mode 100755 new mode 100644 similarity index 89% rename from scripts/collect_licenses.sh rename to scripts/build/collect_licenses.sh index 0dfa602..9311c1c --- a/scripts/collect_licenses.sh +++ b/scripts/build/collect_licenses.sh @@ -2,10 +2,10 @@ set -euo pipefail # Collect third-party license texts into LICENSES.md -# Usage: scripts/collect_licenses.sh [output-path] +# Usage: scripts/build/collect_licenses.sh [output-path] # output-path defaults to LICENSES.md at the repo root -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" OUT="${1:-$REPO_ROOT/LICENSES.md}" FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}" FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}" @@ -59,7 +59,7 @@ for d in data: # ── Frontend (npm) ─────────────────────────────────────────────────── frontend_licenses_local() { cd "$REPO_ROOT/frontend" - node "$REPO_ROOT/scripts/print_frontend_licenses.cjs" + node "$REPO_ROOT/scripts/build/print_frontend_licenses.cjs" } frontend_licenses_docker() { @@ -73,7 +73,7 @@ frontend_licenses_docker() { cd frontend npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null npm ci --ignore-scripts >/dev/null - node /src/scripts/print_frontend_licenses.cjs + node /src/scripts/build/print_frontend_licenses.cjs " } @@ -85,7 +85,7 @@ frontend_licenses() { { echo "# Third-Party Licenses" echo - echo "Auto-generated by \`scripts/collect_licenses.sh\` — do not edit by hand." + echo "Auto-generated by \`scripts/build/collect_licenses.sh\` — do not edit by hand." echo echo "## Backend (Python) Dependencies" echo diff --git a/scripts/print_frontend_licenses.cjs b/scripts/build/print_frontend_licenses.cjs old mode 100755 new mode 100644 similarity index 100% rename from scripts/print_frontend_licenses.cjs rename to scripts/build/print_frontend_licenses.cjs diff --git a/scripts/publish.sh b/scripts/build/publish.sh old mode 100755 new mode 100644 similarity index 92% rename from scripts/publish.sh rename to scripts/build/publish.sh index 1ad6f37..e886751 --- a/scripts/publish.sh +++ b/scripts/build/publish.sh @@ -7,8 +7,8 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color -SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -cd "$SCRIPT_DIR" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" RELEASE_WORK_DIR="" RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore" @@ -17,14 +17,14 @@ DOCKER_IMAGE="jkingsman/remoteterm-meshcore" DOCKER_PLATFORMS="linux/amd64,linux/arm64" cleanup_release_build_artifacts() { - if [ -d "$SCRIPT_DIR/frontend/prebuilt" ]; then - rm -rf "$SCRIPT_DIR/frontend/prebuilt" + if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then + rm -rf "$REPO_ROOT/frontend/prebuilt" fi if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then rm -rf "$RELEASE_WORK_DIR" fi - if [ -n "$RELEASE_ASSET" ] && [ -f "$SCRIPT_DIR/$RELEASE_ASSET" ]; then - rm -f "$SCRIPT_DIR/$RELEASE_ASSET" + if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then + rm -f "$REPO_ROOT/$RELEASE_ASSET" fi } @@ -78,7 +78,7 @@ echo # Run frontend linting and formatting check echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}" -cd "$SCRIPT_DIR/frontend" +cd "$REPO_ROOT/frontend" npm run lint echo -e "${GREEN}Frontend lint passed!${NC}" echo @@ -97,11 +97,11 @@ echo echo -e "${YELLOW}Building frontend...${NC}" npm run build echo -e "${GREEN}Frontend build complete!${NC}" -cd "$SCRIPT_DIR" +cd "$REPO_ROOT" echo echo -e "${YELLOW}Regenerating LICENSES.md...${NC}" -bash scripts/collect_licenses.sh LICENSES.md +bash scripts/build/collect_licenses.sh LICENSES.md echo -e "${GREEN}LICENSES.md updated!${NC}" echo @@ -202,16 +202,16 @@ FULL_GIT_HASH=$(git rev-parse HEAD) RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip" echo -e "${YELLOW}Building packaged frontend artifact...${NC}" -cd "$SCRIPT_DIR/frontend" +cd "$REPO_ROOT/frontend" npm run packaged-build -cd "$SCRIPT_DIR" +cd "$REPO_ROOT" RELEASE_WORK_DIR=$(mktemp -d) RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME" mkdir -p "$RELEASE_BUNDLE_DIR" git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR" mkdir -p "$RELEASE_BUNDLE_DIR/frontend" -cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt" +cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt" cat > "$RELEASE_BUNDLE_DIR/build_info.json" < "$RELEASE_BUNDLE_DIR/build_info.json" < dict: diff --git a/scripts/install_service.sh b/scripts/setup/install_service.sh old mode 100755 new mode 100644 similarity index 98% rename from scripts/install_service.sh rename to scripts/setup/install_service.sh index f51a091..88fca29 --- a/scripts/install_service.sh +++ b/scripts/setup/install_service.sh @@ -7,7 +7,7 @@ # gymnastics. # # Run from anywhere inside the repo: -# bash scripts/install_service.sh +# bash scripts/setup/install_service.sh set -e @@ -19,7 +19,7 @@ BOLD='\033[1m' NC='\033[0m' SERVICE_NAME="remoteterm" -REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" CURRENT_USER="$(id -un)" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" FRONTEND_MODE="build" @@ -252,7 +252,7 @@ if [ "$FRONTEND_MODE" = "build" ]; then ) else echo -e "${YELLOW}Fetching prebuilt frontend...${NC}" - python3 "$REPO_DIR/scripts/fetch_prebuilt_frontend.py" + python3 "$REPO_DIR/scripts/setup/fetch_prebuilt_frontend.py" fi echo @@ -402,7 +402,7 @@ echo -e " cd frontend && npm install && npm run build && cd .." echo -e " sudo systemctl restart ${SERVICE_NAME}" echo echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}" -echo -e " python3 ${REPO_DIR}/scripts/fetch_prebuilt_frontend.py" +echo -e " python3 ${REPO_DIR}/scripts/setup/fetch_prebuilt_frontend.py" echo -e " sudo systemctl restart ${SERVICE_NAME}" echo echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7d21c92..7608015 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1247,8 +1247,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 46 + assert applied == 9 + assert await get_version(conn) == 47 cursor = await conn.execute( """ @@ -1319,8 +1319,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 46 + assert applied == 9 + assert await get_version(conn) == 47 cursor = await conn.execute( """ @@ -1386,8 +1386,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 2 - assert await get_version(conn) == 46 + assert applied == 3 + assert await get_version(conn) == 47 cursor = await conn.execute( """ @@ -1439,8 +1439,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 7 - assert await get_version(conn) == 46 + assert applied == 8 + assert await get_version(conn) == 47 await conn.execute( """ @@ -1501,8 +1501,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 46 + assert applied == 7 + assert await get_version(conn) == 47 await conn.execute( """ @@ -1554,8 +1554,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 5 - assert await get_version(conn) == 46 + assert applied == 6 + assert await get_version(conn) == 47 await conn.execute( """ @@ -1694,8 +1694,8 @@ class TestMigration046: applied = await run_migrations(conn) - assert applied == 1 - assert await get_version(conn) == 46 + assert applied == 2 + assert await get_version(conn) == 47 cursor = await conn.execute( """ @@ -1750,6 +1750,70 @@ class TestMigration046: await conn.close() +class TestMigration047: + """Test migration 047: add statistics indexes.""" + + @pytest.mark.asyncio + async def test_adds_statistics_indexes(self): + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + try: + await set_version(conn, 46) + await conn.execute(""" + CREATE TABLE contacts ( + public_key TEXT PRIMARY KEY, + name TEXT, + type INTEGER DEFAULT 0, + last_seen INTEGER + ) + """) + await conn.execute(""" + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + conversation_key TEXT NOT NULL, + received_at INTEGER NOT NULL + ) + """) + await conn.execute(""" + CREATE TABLE raw_packets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + data BLOB NOT NULL, + message_id INTEGER, + payload_hash BLOB + ) + """) + await conn.commit() + + applied = await run_migrations(conn) + + assert applied == 1 + assert await get_version(conn) == 47 + + cursor = await conn.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND name IN ( + 'idx_raw_packets_timestamp', + 'idx_contacts_type_last_seen', + 'idx_messages_type_received_conversation' + ) + ORDER BY name + """ + ) + rows = await cursor.fetchall() + assert [row["name"] for row in rows] == [ + "idx_contacts_type_last_seen", + "idx_messages_type_received_conversation", + "idx_raw_packets_timestamp", + ] + finally: + await conn.close() + + class TestMigrationPacketHelpers: """Test migration-local packet helpers against canonical path validation.""" From 4847813ae18be9af576737fae6e0927f016ddf3f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 15:59:44 -0700 Subject: [PATCH 06/23] Fix up the slow core query from the stats page. Closes #131. --- app/repository/settings.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/repository/settings.py b/app/repository/settings.py index 78ac729..23c41ce 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -272,17 +272,26 @@ class StatisticsRepository: @staticmethod async def _known_channels_active() -> dict[str, int]: - """Count distinct known channel keys with channel traffic in each time window.""" + """Count known channel keys with any traffic in each time window. + + Channel keys are stored canonically as uppercase hex, so we can avoid + the old UPPER(...) join and aggregate per known channel directly. + """ now = int(time.time()) cursor = await db.conn.execute( """ + WITH known AS ( + SELECT conversation_key, MAX(received_at) AS last_received_at + FROM messages + WHERE type = 'CHAN' + AND conversation_key IN (SELECT key FROM channels) + GROUP BY conversation_key + ) SELECT - COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour, - COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours, - COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week - FROM messages m - INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key) - WHERE m.type = 'CHAN' + SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour, + SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours, + SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week + FROM known """, (now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D), ) From 6534946bc7c8fc1ee5b85d96989f9cccb24fc832 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 16:25:47 -0700 Subject: [PATCH 07/23] Simplify installation instructions --- README.md | 42 ++++++++++++++++-------------------------- README_ADVANCED.md | 25 ------------------------- 2 files changed, 16 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 29b9709..b6e3828 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you ![Screenshot of the application's web interface](app_screenshot.png) -## Disclaimer - -This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs. - -If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`. - ## Start Here Most users should choose one of these paths: @@ -95,30 +89,20 @@ Access the app at http://localhost:8000. Source checkouts expect a normal frontend build in `frontend/dist`. -On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/setup/install_service.sh` from the repo root. +> [!NOTE] +> Running on lightweight hardware/ don't want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`. -## Path 1.5: Use The Prebuilt Release Zip - -Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process. - -If you downloaded the release zip instead of cloning the repo, unpack it and run: - -```bash -cd Remote-Terminal-for-MeshCore -uv sync -uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 -``` - -The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build. - -Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/setup/fetch_prebuilt_frontend.py`. +> [!TIP] +> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure: +> +> ```bash +> bash scripts/setup/install_service.sh +> ``` +> +> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md). ## Path 2: Docker -> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable. - -Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform. - Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then: ```bash @@ -212,3 +196,9 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are - Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md) - Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md) - Live API docs after the backend is running: http://localhost:8000/docs + +## Disclaimer + +This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs. + +If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`. diff --git a/README_ADVANCED.md b/README_ADVANCED.md index c61ed54..ff79af4 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer ## Systemd Service -Two paths are available depending on your comfort level with Linux system administration. - -### Simple install (recommended for most users) - On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required. ```bash bash scripts/setup/install_service.sh ``` -The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics: - You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration. -```bash -# Update to latest and restart -cd /path/to/repo -git pull -uv sync -cd frontend && npm install && npm run build && cd .. -sudo systemctl restart remoteterm - -# Refresh prebuilt frontend only (skips local build) -python3 scripts/setup/fetch_prebuilt_frontend.py -sudo systemctl restart remoteterm - -# View live logs -sudo journalctl -u remoteterm -f - -# Service control -sudo systemctl start|stop|restart|status remoteterm -``` - ## Debug Logging And Bug Reports If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems. From 7460c3ea9d92da27a80cfaf2439c7f3e1fa017eb Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 16:47:24 -0700 Subject: [PATCH 08/23] Add font size slider. Closes #132. --- frontend/AGENTS.md | 3 +- .../settings/SettingsLocalSection.tsx | 110 ++++++++++++++++++ frontend/src/index.css | 2 +- frontend/src/main.tsx | 2 + frontend/src/test/fontScale.test.ts | 66 +++++++++++ frontend/src/test/settingsModal.test.tsx | 56 +++++++++ frontend/src/utils/fontScale.ts | 53 +++++++++ 7 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/fontScale.test.ts create mode 100644 frontend/src/utils/fontScale.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8e23a94..2843206 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -81,6 +81,7 @@ frontend/src/ │ ├── contactMerge.ts # Merge WS contact updates into list │ ├── localLabel.ts # Local label (text + color) in localStorage │ ├── radioPresets.ts # LoRa radio preset configurations +│ ├── fontScale.ts # Browser-local relative font scale persistence/application │ └── theme.ts # Theme switching helpers ├── components/ │ ├── StatusBar.tsx @@ -110,7 +111,7 @@ frontend/src/ │ ├── settings/ │ │ ├── settingsConstants.ts # Settings section type, ordering, labels │ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery -│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation +│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation │ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD │ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label │ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 5b08952..2a7d6d8 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -17,6 +17,14 @@ import { setSavedDistanceUnit, } from '../../utils/distanceUnits'; import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; +import { + DEFAULT_FONT_SCALE, + FONT_SCALE_SLIDER_STEP, + MAX_FONT_SCALE, + MIN_FONT_SCALE, + getSavedFontScale, + setSavedFontScale, +} from '../../utils/fontScale'; export function SettingsLocalSection({ onLocalLabelChange, @@ -31,6 +39,29 @@ export function SettingsLocalSection({ ); const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); + const [fontScale, setFontScale] = useState(getSavedFontScale); + const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); + const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); + + const commitFontScale = (nextScale: number) => { + const normalized = setSavedFontScale(nextScale); + setFontScale(normalized); + setFontScaleSlider(normalized); + setFontScaleInput(String(normalized)); + }; + + const restoreFontScaleInput = () => { + setFontScaleInput(String(fontScale)); + }; + + const handleSliderChange = (nextScale: number) => { + setFontScaleSlider(nextScale); + setFontScaleInput(String(nextScale)); + }; + + const handleSliderCommit = (nextScale: number) => { + commitFontScale(nextScale); + }; const handleToggleReopenLastConversation = (enabled: boolean) => { setReopenLastConversation(enabled); @@ -89,6 +120,85 @@ export function SettingsLocalSection({ +
+ +
+ handleSliderChange(Number(event.target.value))} + onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))} + onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))} + onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))} + onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))} + aria-label="Relative font size slider" + className="w-full accent-primary sm:flex-1" + /> +
+ { + const nextValue = event.target.value; + setFontScaleInput(nextValue); + + if (nextValue === '') { + return; + } + + if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) { + commitFontScale(event.target.valueAsNumber); + } + }} + onBlur={() => { + const parsed = Number.parseFloat(fontScaleInput); + if (!Number.isFinite(parsed)) { + restoreFontScaleInput(); + return; + } + commitFontScale(parsed); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + const parsed = Number.parseFloat(fontScaleInput); + if (!Number.isFinite(parsed)) { + restoreFontScaleInput(); + return; + } + commitFontScale(parsed); + }} + aria-label="Relative font size percentage" + /> + % +
+ +
+

+ Scales the app's typography for this browser only. The slider moves in 5% steps; + the number field accepts any value from 25% to 400%. +

+
+ + +
setSearchQuery(event.target.value)} + placeholder="Search name or public key" + aria-label="Search repeaters" + className="mt-3" + /> +
+ {( + [ + ['alpha', 'Alpha'], + ['recent', 'Recent Heard'], + ['distance', 'Distance'], + ] as const + ).map(([value, label]) => ( + + ))} +
+ {sortMode === 'distance' && !canSortByDistance ? ( +

+ Distance sorting is using known repeater coordinates, but the local radio does not + currently have a valid location. +

+ ) : null} +
+ +
+ {filteredRepeaters.length === 0 ? ( +
+ No repeaters matched this search. +
+ ) : ( +
+ {filteredRepeaters.map((contact) => { + const displayName = getContactDisplayName( + contact.name, + contact.public_key, + contact.last_advert + ); + const distanceKm = getDistanceKm(contact, config); + const selectedCount = draftHops.filter( + (hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key + ).length; + return ( +
0 + ? 'border-primary/30 bg-primary/5' + : 'border-border bg-background hover:bg-accent' + )} + onClick={() => handleAddRepeater(contact.public_key)} + onKeyDown={handleKeyboardActivate} + > + +
+
{displayName}
+
+ {getShortKey(contact.public_key)} +
+ {sortMode === 'distance' && distanceKm !== null ? ( +
+ {distanceKm.toFixed(1)} km away +
+ ) : null} + {selectedCount > 0 ? ( +
+ Added {selectedCount} time{selectedCount === 1 ? '' : 's'} +
+ ) : null} +
+ +
+ ); + })} +
+ )} +
+ + +
+
+
+

Trace Path

+

+ The first node is display-only. The terminal node is the local radio. +

+
+
+ + {draftHops.length === 0 ? ( +
+ Add at least one hop to build a trace loop. +
+ ) : ( + draftHops.map((hop, index) => { + const contact = + hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null; + const displayName = + hop.kind === 'repeater' + ? getContactDisplayName( + contact?.name, + hop.publicKey, + contact?.last_advert ?? null + ) + : 'Custom hop'; + const subtitle = + hop.kind === 'repeater' + ? getShortKey(hop.publicKey) + : `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`; + return ( +
+ + + + + + } + /> +
+ ); + }) + )} + +
+
+
+ {draftHops.length === 0 + ? 'No hops selected' + : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`} +
+ +
+
+ +
+
+

+ Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''} +

+
+
+ {error ? ( +
+ {error} +
+ ) : null} + {!error && !result ? ( +
+ Send a trace to see the returned hop-by-hop SNR values. +
+ ) : null} + {result + ? resultNodes.map((node, index) => { + const title = + node.name || + (node.role === 'custom' + ? 'Custom hop' + : node.role === 'local' + ? localRadioName + : getShortKey(node.public_key)); + const subtitle = + node.role === 'custom' + ? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}` + : node.observed_hash && + node.public_key && + node.observed_hash.toLowerCase() !== + getShortKey(node.public_key).toLowerCase() + ? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}` + : getShortKey(node.public_key); + return ( +
+ +
+ ); + }) + : null} +
+
+
+
+ + + + + Custom path hop + + Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom + hop, all later custom hops must use the same byte width. + + + +
+
+
Hop width
+
+ {([1, 2, 4] as const).map((value) => { + const locked = customHopBytesLocked !== null && customHopBytesLocked !== value; + const active = (customHopBytesLocked ?? customHopBytesDraft) === value; + return ( + + ); + })} +
+ {customHopBytesLocked !== null ? ( +

+ Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace. +

+ ) : null} +
+ +
+ + + setCustomHopHexDraft(normalizeCustomHopHex(event.target.value)) + } + placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`} + /> +

+ Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters. +

+ {customHopError ? ( +
+ {customHopError} +
+ ) : null} +
+
+ + + + + +
+
+ + ); +} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 2a7d6d8..9c886dd 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -192,8 +192,8 @@ export function SettingsLocalSection({

- Scales the app's typography for this browser only. The slider moves in 5% steps; - the number field accepts any value from 25% to 400%. + Scales the app's typography for this browser only. The slider moves in 5% steps; the + number field accepts any value from 25% to 400%.

diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index 167fd0f..5c11b80 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -275,7 +275,9 @@ interface UseConversationMessagesResult { } function isMessageConversation(conversation: Conversation | null): conversation is Conversation { - return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); + return ( + !!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type) + ); } function isActiveConversationMessage( diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 5abd6fe..d0869a4 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -62,7 +62,6 @@ export function useConversationRouter({ // Only needs channels (fast path) - doesn't wait for contacts useEffect(() => { if (hasSetDefaultConversation.current || activeConversation) return; - if (channels.length === 0) return; const hashConv = parseHashSettingsSection() ? null : parseHashConversation(); @@ -92,6 +91,29 @@ export function useConversationRouter({ hasSetDefaultConversation.current = true; return; } + if (hashConv?.type === 'trace') { + setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' }); + hasSetDefaultConversation.current = true; + return; + } + + // No hash: optionally restore last-viewed non-data conversation if enabled on this device. + if (!hashConv && getReopenLastConversationEnabled()) { + const lastViewed = getLastViewedConversation(); + if ( + lastViewed && + (lastViewed.type === 'raw' || + lastViewed.type === 'map' || + lastViewed.type === 'visualizer' || + lastViewed.type === 'trace') + ) { + setActiveConversationState(lastViewed); + hasSetDefaultConversation.current = true; + return; + } + } + + if (channels.length === 0) return; // Handle channel hash (ID-first with legacy-name fallback) if (hashConv?.type === 'channel') { @@ -109,14 +131,6 @@ export function useConversationRouter({ // No hash: optionally restore last-viewed conversation if enabled on this device. if (!hashConv && getReopenLastConversationEnabled()) { const lastViewed = getLastViewedConversation(); - if ( - lastViewed && - (lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer') - ) { - setActiveConversationState(lastViewed); - hasSetDefaultConversation.current = true; - return; - } if (lastViewed?.type === 'channel') { const channel = channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) || diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 8b9f7cd..d0170af 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -195,6 +195,53 @@ describe('App startup hash resolution', () => { }); }); + it('restores the trace tool from the URL hash', async () => { + window.location.hash = '#trace'; + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + + it('restores the trace tool from the URL hash even when channels are unavailable', async () => { + window.location.hash = '#trace'; + mocks.api.getChannels.mockResolvedValue([]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + + it('reopens the last viewed trace tool even when channels are unavailable', async () => { + window.location.hash = ''; + localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1'); + localStorage.setItem( + LAST_VIEWED_CONVERSATION_KEY, + JSON.stringify({ + type: 'trace', + id: 'trace', + name: 'Trace', + }) + ); + mocks.api.getChannels.mockResolvedValue([]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => { const chatChannel = { key: '11111111111111111111111111111111', diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 1fcc9f8..7e81944 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({ VisualizerView: () =>
, })); +vi.mock('../components/TracePane', () => ({ + TracePane: () =>
, +})); + const config: RadioConfig = { public_key: 'aa'.repeat(32), name: 'Radio', @@ -141,6 +145,7 @@ function createProps(overrides: Partial {}), + onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })), onPathDiscovery: vi.fn(async () => { throw new Error('unused'); }), @@ -231,6 +236,23 @@ describe('ConversationPane', () => { }); }); + it('renders the trace tool pane for trace conversations', () => { + render( + + ); + + expect(screen.getByTestId('trace-pane')).toBeInTheDocument(); + expect(screen.queryByTestId('message-list')).not.toBeInTheDocument(); + }); + it('gates room chat behind room login controls until authenticated', async () => { render( ); - return { ...view, flightChannel, opsChannel, aliceName, roomName }; + return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation }; } function getSectionHeaderContainer(title: string): HTMLElement { @@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => { expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + it('shows the trace tool row and selects it', () => { + const { onSelectConversation } = renderSidebar(); + + fireEvent.click(screen.getByText('Trace')); + + expect(onSelectConversation).toHaveBeenCalledWith({ + type: 'trace', + id: 'trace', + name: 'Trace', + }); + }); + it('sorts each section independently and persists per-section sort preferences', () => { const publicChannel = makeChannel('AA'.repeat(16), 'Public'); const zebraChannel = makeChannel('BB'.repeat(16), '#zebra'); diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx new file mode 100644 index 0000000..83d9cca --- /dev/null +++ b/frontend/src/test/tracePane.test.tsx @@ -0,0 +1,262 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { TracePane } from '../components/TracePane'; +import type { Contact, RadioConfig, RadioTraceResponse } from '../types'; +import { CONTACT_TYPE_REPEATER } from '../types'; + +function makeContact( + publicKey: string, + name: string | null, + type = CONTACT_TYPE_REPEATER, + overrides: Partial = {} +): Contact { + return { + public_key: publicKey, + name, + type, + flags: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + ...overrides, + }; +} + +const config: RadioConfig = { + public_key: 'ff'.repeat(32), + name: 'Base Radio', + lat: 10, + lon: 20, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: true, +}; + +describe('TracePane', () => { + it('shows only full-key repeaters and filters by name or key', () => { + render( + + ); + + expect(screen.getByText('Relay Alpha')).toBeInTheDocument(); + expect(screen.getByText('Relay Beta')).toBeInTheDocument(); + expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument(); + expect(screen.queryByText('Client Node')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } }); + expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument(); + expect(screen.getByText('Relay Beta')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } }); + expect(screen.getByText('Relay Alpha')).toBeInTheDocument(); + }); + + it('adds, reorders, removes, and sends a trace path with known repeaters', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 2, + timeout_seconds: 6, + nodes: [ + { + role: 'repeater', + public_key: relayB.public_key, + name: relayB.name, + observed_hash: relayB.public_key.slice(0, 8), + snr: 7.5, + }, + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: relayA.public_key.slice(0, 8), + snr: 3.25, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 5.0, + }, + ], + }) + ); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [ + { public_key: relayB.public_key }, + { public_key: relayA.public_key }, + ]); + }); + + expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument(); + expect(screen.getByText('+7.5 dB')).toBeInTheDocument(); + expect(screen.getByText('+5.0 dB')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i })); + expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i })); + expect(screen.getByText('No hops selected')).toBeInTheDocument(); + }); + + it('allows adding the same repeater multiple times from the picker row', () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText('Added 2 times')).toBeInTheDocument(); + }); + + it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 2, + timeout_seconds: 4.5, + nodes: [ + { + role: 'custom', + public_key: null, + name: null, + observed_hash: 'ae', + snr: 4.0, + }, + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: '11', + snr: 2.0, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 3.0, + }, + ], + }) + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Custom path' })); + fireEvent.click(screen.getByRole('button', { name: '1-byte' })); + fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' })); + + expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument(); + expect(screen.getByText('AE (1-byte)')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(1, [ + { hop_hex: 'ae' }, + { public_key: relayA.public_key }, + ]); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Custom path' })); + expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled(); + expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument(); + }); + + it('drops an in-flight result after the draft path changes', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + let resolveTrace: ((value: RadioTraceResponse) => void) | null = null; + const onRunTracePath = vi.fn( + () => + new Promise((resolve) => { + resolveTrace = resolve; + }) + ); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]); + }); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled(); + + await act(async () => { + resolveTrace?.({ + path_len: 1, + timeout_seconds: 6, + nodes: [ + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: relayA.public_key.slice(0, 8), + snr: 7.5, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 5.0, + }, + ], + }); + }); + + expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument(); + expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument(); + expect( + screen.getByText('Send a trace to see the returned hop-by-hop SNR values.') + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts index 8e3cd1f..57cb8f9 100644 --- a/frontend/src/test/urlHash.test.ts +++ b/frontend/src/test/urlHash.test.ts @@ -52,6 +52,14 @@ describe('parseHashConversation', () => { expect(result).toEqual({ type: 'map', name: 'map' }); }); + it('parses #trace as trace type', () => { + window.location.hash = '#trace'; + + const result = parseHashConversation(); + + expect(result).toEqual({ type: 'trace', name: 'trace' }); + }); + it('parses #map/focus/PUBKEY with focus key', () => { window.location.hash = '#map/focus/ABCD1234'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d54a573..441d8a8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -286,7 +286,7 @@ export interface ResendChannelMessageResponse { message?: Message; } -type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search'; +type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace'; export interface Conversation { type: ConversationType; @@ -474,6 +474,25 @@ export interface TraceResponse { path_len: number; } +export interface RadioTraceNode { + role: 'repeater' | 'custom' | 'local'; + public_key: string | null; + name: string | null; + observed_hash: string | null; + snr: number | null; +} + +export interface RadioTraceHopRequest { + public_key?: string | null; + hop_hex?: string | null; +} + +export interface RadioTraceResponse { + path_len: number; + timeout_seconds: number; + nodes: RadioTraceNode[]; +} + export interface PathDiscoveryRoute { path: string; path_len: number; diff --git a/frontend/src/utils/lastViewedConversation.ts b/frontend/src/utils/lastViewedConversation.ts index 1bd7967..cd661ee 100644 --- a/frontend/src/utils/lastViewedConversation.ts +++ b/frontend/src/utils/lastViewedConversation.ts @@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash'; export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation'; export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation'; -const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer']; +const SUPPORTED_TYPES: Conversation['type'][] = [ + 'contact', + 'channel', + 'raw', + 'map', + 'visualizer', + 'trace', +]; function isSupportedType(value: unknown): value is Conversation['type'] { return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']); @@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void { saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }); return; } + if (hashConversation.type === 'trace') { + saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' }); + return; + } saveLastViewedConversation({ type: hashConversation.type, diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index 929fe5a..0e82243 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey'; import type { SettingsSection } from '../components/settings/settingsConstants'; interface ParsedHashConversation { - type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search'; + type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace'; /** Conversation identity token (channel key or contact public key, or legacy name token) */ name: string; /** Optional human-readable label segment (ignored for identity resolution) */ @@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null { return { type: 'search', name: 'search' }; } + if (hash === 'trace') { + return { type: 'trace', name: 'trace' }; + } + // Check for map with focus: #map/focus/{pubkey_prefix} if (hash.startsWith('map/focus/')) { const focusKey = hash.slice('map/focus/'.length); @@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string { if (conv.type === 'map') return '#map'; if (conv.type === 'visualizer') return '#visualizer'; if (conv.type === 'search') return '#search'; + if (conv.type === 'trace') return '#trace'; // Use immutable IDs for identity, append readable label for UX. if (conv.type === 'channel') { diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index b7eba08..a3a9ac0 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -9,7 +9,7 @@ from fastapi import HTTPException from meshcore import EventType from pydantic import ValidationError -from app.models import Contact +from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest from app.radio import RadioManager, radio_manager from app.routers.radio import ( PrivateKeyUpdate, @@ -25,6 +25,7 @@ from app.routers.radio import ( reconnect_radio, send_advertisement, set_private_key, + trace_path, update_radio_config, ) from app.services.radio_runtime import RadioRuntime @@ -524,6 +525,223 @@ class TestDiscoverMesh: mock_upsert.assert_not_awaited() mock_broadcast.assert_not_called() + +class TestTracePath: + @pytest.mark.asyncio + async def test_returns_resolved_nodes_for_multi_hop_trace(self): + mc = _mock_meshcore_with_info() + repeater_a = Contact( + public_key="11" * 32, + name="Relay Alpha", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + repeater_b = Contact( + public_key="22" * 32, + name="Relay Beta", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 4000}) + ) + mc.wait_for_event = AsyncMock( + return_value=MagicMock( + payload={ + "path_len": 2, + "path": [ + {"hash": "11111111", "snr": 7.5}, + {"hash": "22222222", "snr": 3.25}, + {"snr": 5.0}, + ], + } + ) + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_get.side_effect = [repeater_a, repeater_b] + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[ + RadioTraceHopRequest(public_key=repeater_a.public_key), + RadioTraceHopRequest(public_key=repeater_b.public_key), + ], + ) + ) + + mc.commands.send_trace.assert_awaited_once_with( + path="11111111,22222222", + tag=ANY, + flags=2, + ) + mc.wait_for_event.assert_awaited_once() + assert response.path_len == 2 + assert response.nodes[0].name == "Relay Alpha" + assert response.nodes[0].snr == 7.5 + assert response.nodes[1].name == "Relay Beta" + assert response.nodes[1].observed_hash == "22222222" + assert response.nodes[2].role == "local" + assert response.nodes[2].public_key == "aa" * 32 + assert response.nodes[2].observed_hash is None + assert response.nodes[2].snr == 5.0 + + @pytest.mark.asyncio + async def test_rejects_non_repeater_nodes(self): + mc = _mock_meshcore_with_info() + non_repeater = Contact( + public_key="33" * 32, + name="Client", + type=1, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + ): + mock_get.return_value = non_repeater + with pytest.raises(HTTPException) as exc: + await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[RadioTraceHopRequest(public_key=non_repeater.public_key)], + ) + ) + + assert exc.value.status_code == 400 + assert "not a repeater" in exc.value.detail + + @pytest.mark.asyncio + async def test_returns_504_when_no_trace_response_is_heard(self): + mc = _mock_meshcore_with_info() + repeater = Contact( + public_key="44" * 32, + name="Relay", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 1000}) + ) + mc.wait_for_event = AsyncMock(return_value=None) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_get.return_value = repeater + mock_rm.radio_operation = _noop_radio_operation(mc) + with pytest.raises(HTTPException) as exc: + await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[RadioTraceHopRequest(public_key=repeater.public_key)], + ) + ) + + assert exc.value.status_code == 504 + assert "No trace response heard" in exc.value.detail + + @pytest.mark.asyncio + async def test_supports_custom_hops_with_shorter_hash_width(self): + mc = _mock_meshcore_with_info() + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 2500}) + ) + mc.wait_for_event = AsyncMock( + return_value=MagicMock( + payload={ + "path_len": 2, + "path": [ + {"hash": "ae", "snr": 4.0}, + {"hash": "bf", "snr": 2.5}, + {"snr": 3.0}, + ], + } + ) + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await trace_path( + RadioTraceRequest( + hop_hash_bytes=1, + hops=[ + RadioTraceHopRequest(hop_hex="ae"), + RadioTraceHopRequest(hop_hex="bf"), + ], + ) + ) + + mc.commands.send_trace.assert_awaited_once_with(path="ae,bf", tag=ANY, flags=0) + assert response.nodes[0].role == "custom" + assert response.nodes[0].observed_hash == "ae" + assert response.nodes[1].role == "custom" + assert response.nodes[1].observed_hash == "bf" + @pytest.mark.asyncio async def test_discovers_all_supported_types(self): mc = _mock_meshcore_with_info() From 3a1c2d691b763adb1888eb79aa411d9de421a793 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 20:49:09 -0700 Subject: [PATCH 12/23] Misc. bug bash --- app/event_handlers.py | 2 -- app/radio_sync.py | 20 ++++++++++++++-- app/websocket.py | 3 --- frontend/src/App.tsx | 2 ++ frontend/src/components/SearchView.tsx | 6 ++++- frontend/src/hooks/useRealtimeAppState.ts | 5 ++++ frontend/src/hooks/useUnreadCounts.ts | 23 +++++++++++++++++++ frontend/src/test/useRealtimeAppState.test.ts | 1 + tests/test_event_handlers.py | 3 +-- 9 files changed, 55 insertions(+), 10 deletions(-) diff --git a/app/event_handlers.py b/app/event_handlers.py index 7a96f80..eaf5c43 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -30,8 +30,6 @@ logger = logging.getLogger(__name__) # Track active subscriptions so we can unsubscribe before re-registering # This prevents handler duplication after reconnects _active_subscriptions: list["Subscription"] = [] -_pending_acks = dm_ack_tracker._pending_acks -_buffered_acks = dm_ack_tracker._buffered_acks def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool: diff --git a/app/radio_sync.py b/app/radio_sync.py index eb488c7..8f732ee 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME from app.config import settings -from app.event_handlers import cleanup_expired_acks +from app.event_handlers import cleanup_expired_acks, on_contact_message from app.models import Contact, ContactUpsert from app.radio import RadioOperationBusyError from app.repository import ( @@ -379,6 +379,14 @@ async def _resolve_channel_for_pending_message( return cached_key, channel.name if channel else None +async def _store_pending_direct_message(event) -> None: + """Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path.""" + try: + await on_contact_message(event) + except Exception: + logger.warning("Failed to store pending direct message", exc_info=True) + + async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None: """Persist a CHANNEL_MSG_RECV event pulled via get_msg().""" channel_idx = payload.get("channel_idx") @@ -403,7 +411,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None: return received_at = int(time.time()) - sender_timestamp = payload.get("sender_timestamp") or received_at + ts = payload.get("sender_timestamp") + sender_timestamp = ts if ts is not None else received_at sender_name, message_text = _split_channel_sender_and_text(payload.get("text", "")) await create_fallback_channel_message( @@ -488,6 +497,8 @@ async def drain_pending_messages(mc: MeshCore) -> int: elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV): if result.type == EventType.CHANNEL_MSG_RECV: await _store_pending_channel_message(mc, result.payload) + elif result.type == EventType.CONTACT_MSG_RECV: + await _store_pending_direct_message(result) count += 1 # Small delay between fetches @@ -525,6 +536,8 @@ async def poll_for_messages(mc: MeshCore) -> int: elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV): if result.type == EventType.CHANNEL_MSG_RECV: await _store_pending_channel_message(mc, result.payload) + elif result.type == EventType.CONTACT_MSG_RECV: + await _store_pending_direct_message(result) count += 1 # If we got a message, there might be more - drain them count += await drain_pending_messages(mc) @@ -1018,6 +1031,7 @@ _last_contact_sync: float = 0.0 CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds CONTACT_RECONCILE_BATCH_SIZE = 2 CONTACT_RECONCILE_YIELD_SECONDS = 0.05 +CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0 def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None: @@ -1227,6 +1241,8 @@ async def _reconcile_radio_contacts_in_background( ) except RadioOperationBusyError: logger.debug("Background contact reconcile yielding: radio busy") + await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS) + continue await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS) if not progressed: diff --git a/app/websocket.py b/app/websocket.py index 27ebdb0..81e67c8 100644 --- a/app/websocket.py +++ b/app/websocket.py @@ -43,9 +43,6 @@ class WebSocketManager: 3. Send to all clients concurrently with timeout 4. Re-acquire lock to clean up disconnected clients """ - if not self.active_connections: - return - message = dump_ws_event(event_type, data) # Copy connection list under lock to avoid holding lock during I/O diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b11009a..6d50afb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -274,6 +274,7 @@ export function App() { unreadLastReadAts, recordMessageEvent, renameConversationState, + removeConversationState, markAllRead, refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); @@ -349,6 +350,7 @@ export function App() { observeMessage, recordMessageEvent, renameConversationState, + removeConversationState, checkMention, pendingDeleteFallbackRef, setActiveConversation, diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx index e297a51..7932996 100644 --- a/frontend/src/components/SearchView.tsx +++ b/frontend/src/components/SearchView.tsx @@ -174,7 +174,11 @@ export function SearchView({ api .getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal) .then((data) => { - setResults((prev) => [...prev, ...(data as SearchResult[])]); + setResults((prev) => { + const existingIds = new Set(prev.map((r) => r.id)); + const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id)); + return [...prev, ...unique]; + }); setHasMore(data.length >= SEARCH_PAGE_SIZE); setOffset((prev) => prev + data.length); }) diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index cd1c843..724abb3 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs { hasMention?: boolean; }) => void; renameConversationState: (oldStateKey: string, newStateKey: string) => void; + removeConversationState: (stateKey: string) => void; checkMention: (text: string) => boolean; pendingDeleteFallbackRef: MutableRefObject; setActiveConversation: (conv: Conversation | null) => void; @@ -96,6 +97,7 @@ export function useRealtimeAppState({ observeMessage, recordMessageEvent, renameConversationState, + removeConversationState, checkMention, pendingDeleteFallbackRef, setActiveConversation, @@ -232,6 +234,7 @@ export function useRealtimeAppState({ onContactDeleted: (publicKey: string) => { setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); removeConversationMessages(publicKey); + removeConversationState(getStateKey('contact', publicKey)); const active = activeConversationRef.current; if (active?.type === 'contact' && active.id === publicKey) { pendingDeleteFallbackRef.current = true; @@ -241,6 +244,7 @@ export function useRealtimeAppState({ onChannelDeleted: (key: string) => { setChannels((prev) => prev.filter((c) => c.key !== key)); removeConversationMessages(key); + removeConversationState(getStateKey('channel', key)); const active = activeConversationRef.current; if (active?.type === 'channel' && active.id === key) { pendingDeleteFallbackRef.current = true; @@ -267,6 +271,7 @@ export function useRealtimeAppState({ checkMention, fetchAllContacts, fetchConfig, + removeConversationState, renameConversationState, renameConversationMessages, maxRawPackets, diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 623deb4..97f3382 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -23,6 +23,7 @@ interface UseUnreadCountsResult { hasMention?: boolean; }) => void; renameConversationState: (oldStateKey: string, newStateKey: string) => void; + removeConversationState: (stateKey: string) => void; markAllRead: () => void; refreshUnreads: () => Promise; } @@ -235,6 +236,27 @@ export function useUnreadCounts( setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey)); }, []); + const removeConversationState = useCallback((stateKey: string) => { + setUnreadCounts((prev) => { + if (!(stateKey in prev)) return prev; + const next = { ...prev }; + delete next[stateKey]; + return next; + }); + setMentions((prev) => { + if (!(stateKey in prev)) return prev; + const next = { ...prev }; + delete next[stateKey]; + return next; + }); + setUnreadLastReadAts((prev) => { + if (!(stateKey in prev)) return prev; + const next = { ...prev }; + delete next[stateKey]; + return next; + }); + }, []); + // Mark all conversations as read // Calls single bulk API endpoint to persist read state const markAllRead = useCallback(() => { @@ -256,6 +278,7 @@ export function useUnreadCounts( unreadLastReadAts, recordMessageEvent, renameConversationState, + removeConversationState, markAllRead, refreshUnreads: fetchUnreads, }; diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index 877fa89..05267f1 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial ({ added: false, activeConversation: false })), recordMessageEvent: vi.fn(), renameConversationState: vi.fn(), + removeConversationState: vi.fn(), checkMention: vi.fn(() => false), pendingDeleteFallbackRef: { current: false }, setActiveConversation: vi.fn(), diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py index 7360bd6..5de10b0 100644 --- a/tests/test_event_handlers.py +++ b/tests/test_event_handlers.py @@ -11,8 +11,6 @@ import pytest from app.event_handlers import ( _active_subscriptions, - _buffered_acks, - _pending_acks, cleanup_expired_acks, register_event_handlers, track_pending_ack, @@ -23,6 +21,7 @@ from app.repository import ( ContactRepository, MessageRepository, ) +from app.services.dm_ack_tracker import _buffered_acks, _pending_acks @pytest.fixture(autouse=True) From 26e815009273886fa46b03f949a6528e8f95e18e Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:02:49 -0700 Subject: [PATCH 13/23] Shorten quality script --- scripts/quality/all_quality.sh | 45 ++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/scripts/quality/all_quality.sh b/scripts/quality/all_quality.sh index c91a7b6..6bf7380 100644 --- a/scripts/quality/all_quality.sh +++ b/scripts/quality/all_quality.sh @@ -23,17 +23,17 @@ echo echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}" -echo -e "${BLUE}[backend lint]${NC} Running ruff check + format..." +echo -ne "${BLUE}[backend lint]${NC} " cd "$REPO_ROOT" -uv run ruff check app/ tests/ --fix -uv run ruff format app/ tests/ -echo -e "${GREEN}[backend lint]${NC} Passed!" +uv run ruff check app/ tests/ --fix --quiet +uv run ruff format app/ tests/ --check --quiet +echo -e "${GREEN}Passed!${NC}" -echo -e "${BLUE}[frontend lint]${NC} Running eslint + prettier..." +echo -ne "${BLUE}[frontend lint]${NC} " cd "$REPO_ROOT/frontend" -npm run lint:fix -npm run format -echo -e "${GREEN}[frontend lint]${NC} Passed!" +npx --quiet eslint src/ --fix --cache --quiet +npx --quiet prettier --write --list-different src/ --log-level warn +echo -e "${GREEN}Passed!${NC}" echo -e "${GREEN}=== Phase 1 complete ===${NC}" echo @@ -42,21 +42,30 @@ echo echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}" -echo -e "${BLUE}[pyright]${NC} Running type check..." +echo -ne "${BLUE}[pyright]${NC} " cd "$REPO_ROOT" -uv run pyright app/ -echo -e "${GREEN}[pyright]${NC} Passed!" +uv run pyright app/ --outputjson 2>/dev/null | python3 -c " +import sys, json +d = json.load(sys.stdin) +s = d.get('summary', {}) +print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\") +" 2>/dev/null || { uv run pyright app/; exit 1; } +echo -e "${GREEN}Passed!${NC}" -echo -e "${BLUE}[pytest]${NC} Running backend tests..." +echo -ne "${BLUE}[pytest]${NC} " cd "$REPO_ROOT" -PYTHONPATH=. uv run pytest tests/ -v -echo -e "${GREEN}[pytest]${NC} Passed!" +PYTHONPATH=. uv run pytest tests/ -q --no-header --tb=short +echo -e "${GREEN}Passed!${NC}" -echo -e "${BLUE}[frontend]${NC} Running tests + build..." +echo -ne "${BLUE}[vitest]${NC} " cd "$REPO_ROOT/frontend" -npm run test:run -npm run build -echo -e "${GREEN}[frontend]${NC} Passed!" +npx --quiet vitest run --reporter=dot 2>&1 | tail -5 +echo -e "${GREEN}Passed!${NC}" + +echo -ne "${BLUE}[build]${NC} " +cd "$REPO_ROOT/frontend" +npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1 +echo -e "${GREEN}Passed!${NC}" echo -e "${GREEN}=== Phase 2 complete ===${NC}" echo From 788d1cbdca0041d256f66595134ef7913dbe1cd1 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:13:25 -0700 Subject: [PATCH 14/23] Fix non-repeater traffic during repeater ops dropping messages --- app/routers/repeaters.py | 59 ------------------------ app/routers/server_control.py | 7 ++- tests/test_repeater_routes.py | 86 +++++++++++++++++++++-------------- 3 files changed, 58 insertions(+), 94 deletions(-) diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index fe9744f..8def3a6 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -1,9 +1,6 @@ -import asyncio import logging -from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException -from meshcore import EventType from app.dependencies import require_connected from app.models import ( @@ -28,7 +25,6 @@ from app.models import ( from app.repository import ContactRepository from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 from app.routers.server_control import ( - _monotonic, batch_cli_fetch, extract_response_text, prepare_authenticated_contact_connection, @@ -37,9 +33,6 @@ from app.routers.server_control import ( ) from app.services.radio_runtime import radio_runtime as radio_manager -if TYPE_CHECKING: - from meshcore.events import Event - logger = logging.getLogger(__name__) # ACL permission level names @@ -57,58 +50,6 @@ def _extract_response_text(event) -> str: return extract_response_text(event) -async def _fetch_repeater_response( - mc, - target_pubkey_prefix: str, - timeout: float = 20.0, -) -> "Event | None": - deadline = _monotonic() + timeout - - while _monotonic() < deadline: - try: - result = await mc.commands.get_msg(timeout=2.0) - except asyncio.TimeoutError: - continue - except Exception as exc: - logger.debug("get_msg() exception: %s", exc) - await asyncio.sleep(1.0) - continue - - if result.type == EventType.NO_MORE_MSGS: - await asyncio.sleep(1.0) - continue - - if result.type == EventType.ERROR: - logger.debug("get_msg() error: %s", result.payload) - await asyncio.sleep(1.0) - continue - - if result.type == EventType.CONTACT_MSG_RECV: - msg_prefix = result.payload.get("pubkey_prefix", "") - txt_type = result.payload.get("txt_type", 0) - if msg_prefix == target_pubkey_prefix and txt_type == 1: - return result - logger.debug( - "Skipping non-target message (from=%s, txt_type=%d) while waiting for %s", - msg_prefix, - txt_type, - target_pubkey_prefix, - ) - continue - - if result.type == EventType.CHANNEL_MSG_RECV: - logger.debug( - "Skipping channel message (channel_idx=%s) during repeater fetch", - result.payload.get("channel_idx"), - ) - continue - - logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type) - - logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout) - return None - - async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse: return await prepare_authenticated_contact_connection( mc, diff --git a/app/routers/server_control.py b/app/routers/server_control.py index b3f9e9e..a13ffca 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -13,6 +13,7 @@ from app.models import ( Contact, RepeaterLoginResponse, ) +from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message from app.routers.contacts import _ensure_on_radio from app.services.radio_runtime import radio_runtime as radio_manager @@ -115,18 +116,20 @@ async def fetch_contact_cli_response( if msg_prefix == target_pubkey_prefix and txt_type == 1: return result logger.debug( - "Skipping non-target message (from=%s, txt_type=%d) while waiting for %s", + "Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s", msg_prefix, txt_type, target_pubkey_prefix, ) + await _store_pending_direct_message(result) continue if result.type == EventType.CHANNEL_MSG_RECV: logger.debug( - "Skipping channel message (channel_idx=%s) during CLI fetch", + "Storing channel message (channel_idx=%s) consumed during CLI fetch", result.payload.get("channel_idx"), ) + await _store_pending_channel_message(mc, result.payload) continue logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type) diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index 8d27f64..cb633b1 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -12,7 +12,6 @@ from app.repository import ContactRepository from app.routers.contacts import request_trace from app.routers.repeaters import ( _batch_cli_fetch, - _fetch_repeater_response, prepare_repeater_connection, repeater_acl, repeater_advert_intervals, @@ -25,12 +24,17 @@ from app.routers.repeaters import ( repeater_status, send_repeater_command, ) +from app.routers.server_control import fetch_contact_cli_response KEY_A = "aa" * 32 -# Patch target for the wall-clock wrapper used by _fetch_repeater_response. +# Patch target for the wall-clock wrapper used by fetch_contact_cli_response. # We patch _monotonic (not time.monotonic) to avoid breaking the asyncio event loop. -_MONOTONIC = "app.routers.repeaters._monotonic" +_MONOTONIC = "app.routers.server_control._monotonic" + +# Patch targets for the store helpers called on consumed non-target messages. +_STORE_DM = "app.routers.server_control._store_pending_direct_message" +_STORE_CHAN = "app.routers.server_control._store_pending_channel_message" @pytest.fixture(autouse=True) @@ -104,8 +108,8 @@ def _advancing_clock(start=0.0, step=0.1): return _tick -class TestFetchRepeaterResponse: - """Tests for the _fetch_repeater_response helper.""" +class TestFetchContactCliResponse: + """Tests for the fetch_contact_cli_response helper.""" @pytest.mark.asyncio async def test_returns_matching_cli_response(self): @@ -118,7 +122,7 @@ class TestFetchRepeaterResponse: ) with patch(_MONOTONIC, side_effect=_advancing_clock()): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ok" @@ -138,16 +142,20 @@ class TestFetchRepeaterResponse: ) mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response]) - with patch(_MONOTONIC, side_effect=_advancing_clock()): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + with ( + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch(_STORE_DM, new_callable=AsyncMock) as store_dm, + ): + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ver 1.0" assert mc.commands.get_msg.await_count == 2 + store_dm.assert_awaited_once_with(non_cli) @pytest.mark.asyncio - async def test_unrelated_dm_is_skipped(self): - """Unrelated DMs are skipped (dispatcher already handled them).""" + async def test_unrelated_dm_is_stored(self): + """Unrelated DMs consumed during CLI fetch are stored, not discarded.""" mc = _mock_mc() unrelated = _radio_result( EventType.CONTACT_MSG_RECV, @@ -159,14 +167,18 @@ class TestFetchRepeaterResponse: ) mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected]) - with patch(_MONOTONIC, side_effect=_advancing_clock()): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + with ( + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch(_STORE_DM, new_callable=AsyncMock) as store_dm, + ): + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ver 1.0" + store_dm.assert_awaited_once_with(unrelated) @pytest.mark.asyncio - async def test_channel_message_is_skipped(self): + async def test_channel_message_is_stored(self): mc = _mock_mc() channel_msg = _radio_result( EventType.CHANNEL_MSG_RECV, @@ -178,11 +190,15 @@ class TestFetchRepeaterResponse: ) mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected]) - with patch(_MONOTONIC, side_effect=_advancing_clock()): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + with ( + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch(_STORE_CHAN, new_callable=AsyncMock) as store_chan, + ): + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ok" + store_chan.assert_awaited_once_with(mc, channel_msg.payload) @pytest.mark.asyncio async def test_no_more_msgs_retries_then_succeeds(self): @@ -196,9 +212,9 @@ class TestFetchRepeaterResponse: with ( patch(_MONOTONIC, side_effect=_advancing_clock()), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ok" @@ -215,9 +231,9 @@ class TestFetchRepeaterResponse: with ( patch(_MONOTONIC, side_effect=times), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=2.0) + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=2.0) assert result is None @@ -233,16 +249,16 @@ class TestFetchRepeaterResponse: with ( patch(_MONOTONIC, side_effect=_advancing_clock()), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0) + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0) assert result is not None assert result.payload["text"] == "ok" @pytest.mark.asyncio - async def test_high_traffic_does_not_exhaust_budget(self): - """Many unrelated messages don't prevent eventual success (wall-clock deadline).""" + async def test_high_traffic_stores_all_consumed_messages(self): + """Many unrelated messages are stored and don't prevent eventual success.""" mc = _mock_mc() # 20 unrelated DMs followed by the expected CLI response unrelated = [ @@ -258,12 +274,16 @@ class TestFetchRepeaterResponse: ) mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected]) - with patch(_MONOTONIC, side_effect=_advancing_clock()): - result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0) + with ( + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch(_STORE_DM, new_callable=AsyncMock) as store_dm, + ): + result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=30.0) assert result is not None assert result.payload["text"] == "ver 1.0" assert mc.commands.get_msg.await_count == 21 + assert store_dm.await_count == 20 class TestRepeaterCommandRoute: @@ -297,7 +317,7 @@ class TestRepeaterCommandRoute: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) @@ -457,7 +477,7 @@ class TestRepeaterCommandRoute: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=_advancing_clock()), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) @@ -998,7 +1018,7 @@ class TestRepeaterRadioSettings: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=clock_ticks), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await repeater_radio_settings(KEY_A) @@ -1073,7 +1093,7 @@ class TestRepeaterNodeInfo: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=clock_ticks), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await repeater_node_info(KEY_A) @@ -1126,7 +1146,7 @@ class TestRepeaterAdvertIntervals: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=clock_ticks), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await repeater_advert_intervals(KEY_A) @@ -1181,7 +1201,7 @@ class TestRepeaterOwnerInfo: patch("app.routers.repeaters.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=clock_ticks), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): response = await repeater_owner_info(KEY_A) @@ -1239,7 +1259,7 @@ class TestBatchCliFetch: with ( patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=_advancing_clock()), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): results = await _batch_cli_fetch( contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")] @@ -1260,7 +1280,7 @@ class TestBatchCliFetch: with ( patch.object(radio_manager, "_meshcore", mc), patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]), - patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), ): results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")]) From 44d6fcac24fb8623056de202d5885c53a7b199c6 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:15:42 -0700 Subject: [PATCH 15/23] Add missing abort controller --- frontend/src/api.ts | 6 ++++-- frontend/src/components/ContactInfoPane.tsx | 16 ++++++++-------- frontend/src/test/contactInfoPane.test.tsx | 5 ++++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ace9789..8872cff 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -137,11 +137,13 @@ export const api = { fetchJson( `/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}` ), - getContactAnalytics: (params: { publicKey?: string; name?: string }) => { + getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => { const searchParams = new URLSearchParams(); if (params.publicKey) searchParams.set('public_key', params.publicKey); if (params.name) searchParams.set('name', params.name); - return fetchJson(`/contacts/analytics?${searchParams.toString()}`); + return fetchJson(`/contacts/analytics?${searchParams.toString()}`, { + signal, + }); }, deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 59587ce..3f145fa 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -10,7 +10,7 @@ import { ResponsiveContainer, Legend, } from 'recharts'; -import { api } from '../api'; +import { api, isAbortError } from '../api'; import { formatTime } from '../utils/messageParser'; import { getContactDisplayName, @@ -110,29 +110,29 @@ export function ContactInfoPane({ return; } - let cancelled = false; + const controller = new AbortController(); setAnalytics(null); setLoading(true); const request = isNameOnly && nameOnlyValue - ? api.getContactAnalytics({ name: nameOnlyValue }) - : api.getContactAnalytics({ publicKey: contactKey }); + ? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal) + : api.getContactAnalytics({ publicKey: contactKey }, controller.signal); request .then((data) => { - if (!cancelled) setAnalytics(data); + if (!controller.signal.aborted) setAnalytics(data); }) .catch((err) => { - if (!cancelled) { + if (!isAbortError(err)) { console.error('Failed to fetch contact analytics:', err); toast.error('Failed to load contact info'); } }) .finally(() => { - if (!cancelled) setLoading(false); + if (!controller.signal.aborted) setLoading(false); }); return () => { - cancelled = true; + controller.abort(); }; }, [contactKey, isNameOnly, nameOnlyValue]); diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index e518694..58df85c 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -181,7 +181,10 @@ describe('ContactInfoPane', () => { await screen.findByText('Mystery'); await waitFor(() => { - expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' }); + expect(getContactAnalytics).toHaveBeenCalledWith( + { name: 'Mystery' }, + expect.any(AbortSignal) + ); expect(screen.getByText('Messages')).toBeInTheDocument(); expect(screen.getByText('Channel Messages')).toBeInTheDocument(); expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument(); From 7b9d8f6a2371bd085ee4e5334d30d25cc50782f4 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:24:36 -0700 Subject: [PATCH 16/23] Docs updates --- AGENTS.md | 41 ++++++++++++++++++-------------- CONTRIBUTING.md | 1 + app/AGENTS.md | 36 ++++++++++++++++++++-------- app/packet_processor.py | 7 +++--- frontend/AGENTS.md | 52 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 102 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07d1c11..5754ffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -277,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v ``` Key test files: -- `tests/test_decoder.py` - Channel + direct message decryption, key exchange -- `tests/test_keystore.py` - Ephemeral key store -- `tests/test_event_handlers.py` - ACK tracking, repeat detection -- `tests/test_packet_pipeline.py` - End-to-end packet processing -- `tests/test_api.py` - API endpoints, read state tracking -- `tests/test_migrations.py` - Database migration system -- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling) -- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination -- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration -- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring -- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers -- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows -- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field -- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast) -- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio -- `tests/test_real_crypto.py` - Real cryptographic operations -- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature +- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows +- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation +- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup +- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring +- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints +- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints +- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints +- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints +- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes +- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch +- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior +- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots +- `tests/test_version_info.py` - Version/build metadata resolution +- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior +- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior + +For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`. ### Frontend (Vitest) @@ -319,6 +319,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | PUT | `/api/radio/private-key` | Import private key to radio | | POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) | | POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors | +| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio | | POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected | | POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts | | POST | `/api/radio/reconnect` | Manual radio reconnection | @@ -341,6 +342,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI | | POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals | | POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info | +| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server | +| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | +| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | +| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries | | GET | `/api/channels` | List channels | | GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) | @@ -354,6 +359,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/messages/channel` | Send channel message | | POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) | | GET | `/api/packets/undecrypted/count` | Count of undecrypted packets | +| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection | | POST | `/api/packets/decrypt/historical` | Decrypt stored packets | | POST | `/api/packets/maintenance` | Delete old packets and vacuum | | GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries | @@ -368,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/fanout` | Create new fanout config | | PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) | | DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) | +| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts | | GET | `/api/statistics` | Aggregated mesh network statistics | | WS | `/api/ws` | Real-time updates | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e54d449..256d09a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme ```bash cd tests/e2e +npm install npx playwright test # headless npx playwright test --headed # you can probably guess ``` diff --git a/app/AGENTS.md b/app/AGENTS.md index 105fe7f..3c44261 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior. app/ ├── main.py # App startup/lifespan, router registration, static frontend mounting ├── config.py # Env-driven runtime settings +├── channel_constants.py # Public/default channel constants shared across sync/send logic ├── database.py # SQLite connection + base schema + migration runner ├── migrations.py # Schema migrations (SQLite user_version) ├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert) +├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces ├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout) ├── services/ # Shared orchestration/domain services │ ├── messages.py # Shared message creation, dedup, ACK application │ ├── message_send.py # Direct send, channel send, resend workflows │ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths +│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state │ ├── dm_ack_tracker.py # Pending DM ACK state │ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring │ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers │ ├── radio_commands.py # Radio config/private-key command workflows +│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history │ └── radio_runtime.py # Router/dependency seam over the global RadioManager ├── radio.py # RadioManager transport/session state + lock management ├── radio_sync.py # Polling, sync, periodic advertisement loop @@ -61,6 +65,8 @@ app/ ├── messages.py ├── packets.py ├── read_state.py + ├── rooms.py + ├── server_control.py ├── settings.py ├── fanout.py ├── repeaters.py @@ -174,6 +180,7 @@ app/ - `PUT /radio/private-key` - `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`) - `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors +- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio - `POST /radio/disconnect` - `POST /radio/reboot` - `POST /radio/reconnect` @@ -198,6 +205,10 @@ app/ - `POST /contacts/{public_key}/repeater/radio-settings` - `POST /contacts/{public_key}/repeater/advert-intervals` - `POST /contacts/{public_key}/repeater/owner-info` +- `POST /contacts/{public_key}/room/login` +- `POST /contacts/{public_key}/room/status` +- `POST /contacts/{public_key}/room/lpp-telemetry` +- `POST /contacts/{public_key}/room/acl` ### Channels - `GET /channels` @@ -216,6 +227,7 @@ app/ ### Packets - `GET /packets/undecrypted/count` +- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection - `POST /packets/decrypt/historical` - `POST /packets/maintenance` @@ -236,6 +248,7 @@ app/ - `POST /fanout` — create new fanout config - `PATCH /fanout/{id}` — update fanout config (triggers module reload) - `DELETE /fanout/{id}` — delete fanout config (stops module) +- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart ### Statistics - `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels) @@ -322,9 +335,11 @@ tests/ ├── conftest.py # Shared fixtures ├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring ├── test_api.py # REST endpoint integration tests +├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces ├── test_bot.py # Bot execution and sandboxing -├── test_channels_router.py # Channels router endpoints ├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages +├── test_channels_router.py # Channels router endpoints +├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast) ├── test_config.py # Configuration validation ├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers ├── test_contacts_router.py # Contacts router endpoints @@ -332,40 +347,41 @@ tests/ ├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature ├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent) ├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch -├── test_fanout_integration.py # Fanout integration tests ├── test_fanout_hitlist.py # Fanout-related hitlist regression tests +├── test_fanout_integration.py # Fanout integration tests ├── test_event_handlers.py # ACK tracking, event registration, cleanup ├── test_frontend_static.py # Frontend static file serving ├── test_health_mqtt_status.py # Health endpoint MQTT status field ├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks ├── test_key_normalization.py # Public key normalization ├── test_keystore.py # Ephemeral keystore +├── test_main_startup.py # App startup and lifespan +├── test_map_upload.py # Map upload fanout module ├── test_message_pagination.py # Cursor-based message pagination ├── test_message_prefix_claim.py # Message prefix claim logic -├── test_migrations.py # Schema migration system -├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast) ├── test_mqtt.py # MQTT publisher topic routing and lifecycle +├── test_messages_search.py # Message search, around, forward pagination +├── test_migrations.py # Schema migration system ├── test_packet_pipeline.py # End-to-end packet processing ├── test_packets_router.py # Packets router endpoints (decrypt, maintenance) +├── test_path_utils.py # Path hex rendering helpers ├── test_radio.py # RadioManager, serial detection ├── test_radio_commands_service.py # Radio config/private-key service workflows ├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers -├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers -├── test_real_crypto.py # Real cryptographic operations ├── test_radio_operation.py # radio_operation() context manager ├── test_radio_router.py # Radio router endpoints +├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers ├── test_radio_sync.py # Polling, sync, advertisement +├── test_real_crypto.py # Real cryptographic operations ├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints ├── test_repository.py # Data access layer +├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints ├── test_rx_log_data.py # on_rx_log_data event handler integration -├── test_messages_search.py # Message search, around, forward pagination -├── test_block_lists.py # Blocked keys/names filtering ├── test_security.py # Optional Basic Auth middleware / config behavior ├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends ├── test_settings_router.py # Settings endpoints, advert validation ├── test_statistics.py # Statistics aggregation -├── test_main_startup.py # App startup and lifespan -├── test_path_utils.py # Path hex rendering helpers +├── test_version_info.py # Version/build metadata resolution ├── test_websocket.py # WS manager broadcast/cleanup └── test_websocket_route.py # WS endpoint lifecycle ``` diff --git a/app/packet_processor.py b/app/packet_processor.py index 12ac466..32ddd9f 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -264,9 +264,10 @@ async def process_raw_packet( This is the main entry point for all incoming RF packets. Note: Packets are deduplicated by payload hash in the database. If we receive - a duplicate packet (same payload, different path), we still broadcast it to - the frontend (for the real-time packet feed) but skip decryption processing - since the original packet was already processed. + a duplicate payload (same payload, different path), we still broadcast it to + the frontend for realtime packet-feed fidelity. Some payload types are also + intentionally reprocessed on duplicate arrival so message-level dedup/path + merge logic and advert/path-history tracking still see each observation. """ ts = timestamp or int(time.time()) observation_id = next(_raw_observation_counter) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 2843206..5c44739 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -39,6 +39,8 @@ frontend/src/ ├── index.css # Global styles/utilities ├── styles.css # Additional global app styles ├── themes.css # Color theme definitions +├── contexts/ +│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider ├── lib/ │ └── utils.ts # cn() — clsx + tailwind-merge helper ├── hooks/ @@ -53,10 +55,14 @@ frontend/src/ │ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps │ ├── useAppSettings.ts # Settings, favorites, preferences migration │ ├── useConversationRouter.ts # URL hash → active conversation routing -│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion +│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion +│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch +│ ├── useFaviconBadge.ts # Browser tab unread badge state +│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history +│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence ├── components/ -│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals -│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty) +│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning +│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty) │ ├── visualizer/ │ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state │ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction @@ -73,14 +79,17 @@ frontend/src/ │ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback) │ ├── contactAvatar.ts # Avatar color derivation from public key │ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers +│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers │ ├── regionScope.ts # Regional flood-scope label/normalization helpers │ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles │ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options │ ├── a11y.ts # Keyboard accessibility helper +│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers │ ├── lastViewedConversation.ts # localStorage for last-viewed conversation │ ├── contactMerge.ts # Merge WS contact updates into list │ ├── localLabel.ts # Local label (text + color) in localStorage │ ├── radioPresets.ts # LoRa radio preset configurations +│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults │ ├── fontScale.ts # Browser-local relative font scale persistence/application │ └── theme.ts # Theme switching helpers ├── components/ @@ -92,8 +101,12 @@ frontend/src/ │ ├── NewMessageModal.tsx │ ├── SearchView.tsx # Full-text message search pane │ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections +│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture │ ├── RawPacketList.tsx +│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer +│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog │ ├── MapView.tsx +│ ├── TracePane.tsx # Multi-hop route trace builder/results view │ ├── VisualizerView.tsx │ ├── PacketVisualizer3D.tsx │ ├── PathModal.tsx @@ -103,9 +116,14 @@ frontend/src/ │ ├── ContactAvatar.tsx │ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths) │ ├── ContactStatusInfo.tsx # Contact status info component +│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog +│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor │ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes │ ├── RepeaterLogin.tsx # Repeater login form (password + guest) +│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat +│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner │ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders) +│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor │ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard │ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations │ ├── settings/ @@ -131,12 +149,13 @@ frontend/src/ │ └── ui/ # shadcn/ui primitives ├── types/ │ └── d3-force-3d.d.ts # Type declarations for d3-force-3d -└── test/ +└── test/ # Representative frontend test suites (not an exhaustive listing) ├── setup.ts ├── fixtures/websocket_events.json ├── api.test.ts ├── appFavorites.test.tsx ├── appStartupHash.test.tsx + ├── conversationPane.test.tsx ├── contactAvatar.test.ts ├── contactInfoPane.test.tsx ├── integration.test.ts @@ -147,18 +166,23 @@ frontend/src/ ├── rawPacketList.test.tsx ├── pathUtils.test.ts ├── prefetch.test.ts + ├── rawPacketDetailModal.test.tsx + ├── rawPacketFeedView.test.tsx ├── radioPresets.test.ts ├── rawPacketIdentity.test.ts ├── repeaterDashboard.test.tsx ├── repeaterFormatters.test.ts ├── repeaterLogin.test.tsx ├── repeaterMessageParsing.test.ts + ├── roomServerPanel.test.tsx + ├── securityWarningModal.test.tsx ├── localLabel.test.ts ├── messageInput.test.tsx ├── newMessageModal.test.tsx ├── settingsModal.test.tsx ├── sidebar.test.tsx ├── statusBar.test.tsx + ├── tracePane.test.tsx ├── unreadCounts.test.ts ├── urlHash.test.ts ├── appSearchJump.test.tsx @@ -170,12 +194,17 @@ frontend/src/ ├── useConversationMessages.race.test.ts ├── useConversationNavigation.test.ts ├── useAppShell.test.ts + ├── useBrowserNotifications.test.ts + ├── useFaviconBadge.test.ts ├── useRepeaterDashboard.test.ts + ├── useRememberedServerPassword.test.ts ├── useContactsAndChannels.test.ts ├── useRealtimeAppState.test.ts ├── useUnreadCounts.test.ts ├── useWebSocket.dispatch.test.ts ├── useWebSocket.lifecycle.test.ts + ├── rawPacketStats.test.ts + ├── fontScale.test.ts └── wsEvents.test.ts ``` @@ -191,6 +220,7 @@ frontend/src/ - search/settings surface switching - global cracker mount/focus behavior - new-message modal and info panes +- trusted-network `SecurityWarningModal` High-level state is delegated to hooks: - `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal) @@ -212,7 +242,9 @@ High-level state is delegated to hooks: - map view - visualizer - raw packet feed +- trace view - repeater dashboard +- room-server auth/status gate before room chat - normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`) ### Initial load + realtime @@ -273,12 +305,16 @@ Supported routes: - `#map/focus/{pubkey_or_prefix}` - `#visualizer` - `#search` +- `#trace` +- `#settings/{section}` - `#channel/{channelKey}` - `#channel/{channelKey}/{label}` - `#contact/{publicKey}` - `#contact/{publicKey}/{label}` -Legacy name-based hashes are still accepted for compatibility. +Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`. + +Legacy name-based channel/contact hashes are still accepted for compatibility. ## Conversation State Keys (`utils/conversationState.ts`) @@ -378,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo All state is managed by `useRepeaterDashboard` hook. State resets on conversation change. +## Room Server Panel + +For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required. + +`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display. + ## Message Search Pane The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: From 3c0d6a44668804dc01842bab538edc5157fcad94 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:29:01 -0700 Subject: [PATCH 17/23] Fix some misc. frontend correctness bugs --- frontend/src/components/MapView.tsx | 14 ++++++++ frontend/src/components/MessageList.tsx | 17 ++++++++- frontend/src/hooks/useUnreadCounts.ts | 27 +++++--------- frontend/src/test/useUnreadCounts.test.ts | 43 +++++++++++++++++++++++ 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 7106594..8166e16 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) { // Store ref for a marker const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => { + if (ref === null) { + delete markerRefs.current[key]; + return; + } + markerRefs.current[key] = ref; }, []); + useEffect(() => { + const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key)); + for (const key of Object.keys(markerRefs.current)) { + if (!currentKeys.has(key)) { + delete markerRefs.current[key]; + } + } + }, [mappableContacts]); + // Open popup for focused contact after map is ready useEffect(() => { if (focusedContact && markerRefs.current[focusedContact.public_key]) { diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 06f46f8..ea39063 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -373,7 +373,22 @@ export function MessageList({ } } - setResendableIds(newResendable); + setResendableIds((prev) => { + if (prev.size === newResendable.size) { + let changed = false; + for (const id of newResendable) { + if (!prev.has(id)) { + changed = true; + break; + } + } + if (!changed) { + return prev; + } + } + + return newResendable; + }); return () => { for (const timer of timers.values()) clearTimeout(timer); diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 97f3382..0180901 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -10,6 +10,12 @@ import { import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types'; import { takePrefetchOrFetch } from '../prefetch'; +function isUnreadTrackedConversation( + conversation: Conversation | null +): conversation is Extract { + return conversation?.type === 'channel' || conversation?.type === 'contact'; +} + interface UseUnreadCountsResult { unreadCounts: Record; /** Tracks which conversations have unread messages that mention the user */ @@ -48,14 +54,7 @@ export function useUnreadCounts( // (the user is already viewing it, so its count should stay at 0). const applyUnreads = useCallback((data: UnreadCounts) => { const ac = activeConvRef.current; - const activeKey = - ac && - ac.type !== 'raw' && - ac.type !== 'map' && - ac.type !== 'visualizer' && - ac.type !== 'search' - ? getStateKey(ac.type as 'channel' | 'contact', ac.id) - : null; + const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null; if (activeKey) { const counts = { ...data.counts }; @@ -123,16 +122,8 @@ export function useUnreadCounts( // Mark conversation as read when user views it // Calls server API to persist read state across devices useEffect(() => { - if ( - activeConversation && - activeConversation.type !== 'raw' && - activeConversation.type !== 'map' && - activeConversation.type !== 'visualizer' - ) { - const key = getStateKey( - activeConversation.type as 'channel' | 'contact', - activeConversation.id - ); + if (isUnreadTrackedConversation(activeConversation)) { + const key = getStateKey(activeConversation.type, activeConversation.id); // Update local state immediately for responsive UI setUnreadCounts((prev) => { diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts index 82bfb87..cf90124 100644 --- a/frontend/src/test/useUnreadCounts.test.ts +++ b/frontend/src/test/useUnreadCounts.test.ts @@ -221,6 +221,49 @@ describe('useUnreadCounts', () => { }); }); + it('does not treat search or trace views as readable conversations', async () => { + const mocks = await getMockedApi(); + mocks.getUnreads.mockResolvedValue({ + counts: { + [getStateKey('channel', CHANNEL_KEY)]: 4, + [getStateKey('contact', CONTACT_KEY)]: 2, + }, + mentions: { + [getStateKey('channel', CHANNEL_KEY)]: true, + }, + last_message_times: {}, + last_read_ats: {}, + }); + + const { result, rerender } = renderWith({ + channels: [makeChannel(CHANNEL_KEY, 'Test')], + contacts: [makeContact(CONTACT_KEY)], + activeConversation: { type: 'search', id: 'search', name: 'Message Search' }, + }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled()); + }); + + expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4); + expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2); + expect(mocks.markChannelRead).not.toHaveBeenCalled(); + expect(mocks.markContactRead).not.toHaveBeenCalled(); + + rerender({ + channels: [makeChannel(CHANNEL_KEY, 'Test')], + contacts: [makeContact(CONTACT_KEY)], + activeConversation: { type: 'trace', id: 'trace', name: 'Trace' }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.markChannelRead).not.toHaveBeenCalled(); + expect(mocks.markContactRead).not.toHaveBeenCalled(); + }); + it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => { const mocks = await getMockedApi(); const channels = [makeChannel(CHANNEL_KEY, 'Test')]; From 5c60559cb8bd572038788dd1e13e79f145fbc255 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:31:47 -0700 Subject: [PATCH 18/23] Fix memoization on cracker panel --- frontend/src/components/CrackerPanel.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index 8336fe1..58df592 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -128,8 +128,9 @@ export function CrackerPanel({ }, [existingChannelKeys]); // Filter packets to only undecrypted GROUP_TEXT - const undecryptedGroupText = packets.filter( - (p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted + const undecryptedGroupText = useMemo( + () => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted), + [packets] ); // Update queue when packets change (deduplicated by payload) From 43abcd07b20bf48dde7d5131a63a2560ea31db4d Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:31:59 -0700 Subject: [PATCH 19/23] Improve DB streaming perf for cracking and statistics --- app/packet_processor.py | 20 ++++++---- app/repository/raw_packets.py | 52 +++++++++++++++++++------- app/repository/settings.py | 70 ++++++++++++++++++++--------------- app/routers/debug.py | 8 ++-- app/routers/packets.py | 3 +- tests/test_api.py | 23 ++++++++++++ tests/test_packets_router.py | 39 ++++++++++++++++++- tests/test_statistics.py | 47 +++++++++++++++++++++++ 8 files changed, 203 insertions(+), 59 deletions(-) diff --git a/app/packet_processor.py b/app/packet_processor.py index 32ddd9f..8a43618 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -122,20 +122,20 @@ async def run_historical_dm_decryption( """Background task to decrypt historical DM packets with contact's key.""" from app.websocket import broadcast_success - packets = await RawPacketRepository.get_undecrypted_text_messages() - total = len(packets) + total = 0 decrypted_count = 0 - if total == 0: - logger.info("No undecrypted TEXT_MESSAGE packets to process") - return - - logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total) + logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets") # Derive our public key from the private key our_public_key_bytes = derive_public_key(private_key_bytes) - for packet_id, packet_data, packet_timestamp in packets: + async for ( + packet_id, + packet_data, + packet_timestamp, + ) in RawPacketRepository.stream_undecrypted_text_messages(): + total += 1 # Note: passing our_public_key=None disables the outbound hash check in # try_decrypt_dm (only the inbound check src_hash == their_first_byte runs). # For the 255/256 case where our first byte differs from the contact's, @@ -187,6 +187,10 @@ async def run_historical_dm_decryption( if msg_id is not None: decrypted_count += 1 + if total == 0: + logger.info("No undecrypted TEXT_MESSAGE packets to process") + return + logger.info( "Historical DM decryption complete: %d/%d packets decrypted", decrypted_count, diff --git a/app/repository/raw_packets.py b/app/repository/raw_packets.py index c773a67..29ead2c 100644 --- a/app/repository/raw_packets.py +++ b/app/repository/raw_packets.py @@ -1,6 +1,7 @@ import logging import sqlite3 import time +from collections.abc import AsyncIterator from hashlib import sha256 from app.database import db @@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type logger = logging.getLogger(__name__) +UNDECRYPTED_PACKET_BATCH_SIZE = 500 + class RawPacketRepository: @staticmethod @@ -100,6 +103,40 @@ class RawPacketRepository: rows = await cursor.fetchall() return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows] + @staticmethod + async def stream_undecrypted_text_messages( + batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE, + ) -> AsyncIterator[tuple[int, bytes, int]]: + """Yield undecrypted TEXT_MESSAGE packets in bounded-size batches.""" + cursor = await db.conn.execute( + "SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC" + ) + try: + while True: + rows = await cursor.fetchmany(batch_size) + if not rows: + break + + for row in rows: + data = bytes(row["data"]) + payload_type = get_packet_payload_type(data) + if payload_type == PayloadType.TEXT_MESSAGE: + yield (row["id"], data, row["timestamp"]) + finally: + await cursor.close() + + @staticmethod + async def count_undecrypted_text_messages( + batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE, + ) -> int: + """Count undecrypted TEXT_MESSAGE packets without materializing them all.""" + count = 0 + async for _packet in RawPacketRepository.stream_undecrypted_text_messages( + batch_size=batch_size + ): + count += 1 + return count + @staticmethod async def mark_decrypted(packet_id: int, message_id: int) -> None: """Link a raw packet to its decrypted message.""" @@ -158,17 +195,4 @@ class RawPacketRepository: Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02). These are direct messages that can be decrypted with contact ECDH keys. """ - cursor = await db.conn.execute( - "SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC" - ) - rows = await cursor.fetchall() - - # Filter for TEXT_MESSAGE packets - result = [] - for row in rows: - data = bytes(row["data"]) - payload_type = get_packet_payload_type(data) - if payload_type == PayloadType.TEXT_MESSAGE: - result.append((row["id"], data, row["timestamp"])) - - return result + return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()] diff --git a/app/repository/settings.py b/app/repository/settings.py index 23c41ce..2428feb 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) SECONDS_1H = 3600 SECONDS_24H = 86400 SECONDS_7D = 604800 +RAW_PACKET_STATS_BATCH_SIZE = 500 class AppSettingsRepository: @@ -246,6 +247,26 @@ class AppSettingsRepository: class StatisticsRepository: + @staticmethod + async def get_database_message_totals() -> dict[str, int]: + """Return message totals needed by lightweight debug surfaces.""" + cursor = await db.conn.execute( + """ + SELECT + SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms, + SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages, + SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing + FROM messages + """ + ) + row = await cursor.fetchone() + assert row is not None + return { + "total_dms": row["total_dms"] or 0, + "total_channel_messages": row["total_channel_messages"] or 0, + "total_outgoing": row["total_outgoing"] or 0, + } + @staticmethod async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]: """Get time-windowed counts for contacts/repeaters heard.""" @@ -311,22 +332,26 @@ class StatisticsRepository: "SELECT data FROM raw_packets WHERE timestamp >= ?", (now - SECONDS_24H,), ) - rows = await cursor.fetchall() single_byte = 0 double_byte = 0 triple_byte = 0 - for row in rows: - envelope = parse_packet_envelope(bytes(row["data"])) - if envelope is None: - continue - if envelope.hash_size == 1: - single_byte += 1 - elif envelope.hash_size == 2: - double_byte += 1 - elif envelope.hash_size == 3: - triple_byte += 1 + while True: + rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE) + if not rows: + break + + for row in rows: + envelope = parse_packet_envelope(bytes(row["data"])) + if envelope is None: + continue + if envelope.hash_size == 1: + single_byte += 1 + elif envelope.hash_size == 2: + double_byte += 1 + elif envelope.hash_size == 3: + triple_byte += 1 total_packets = single_byte + double_byte + triple_byte if total_packets == 0: @@ -409,22 +434,7 @@ class StatisticsRepository: decrypted_packets = pkt_row["decrypted"] or 0 undecrypted_packets = total_packets - decrypted_packets - # Message type counts - cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'") - row = await cursor.fetchone() - assert row is not None - total_dms: int = row["cnt"] - - cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'") - row = await cursor.fetchone() - assert row is not None - total_channel_messages: int = row["cnt"] - - # Outgoing count - cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1") - row = await cursor.fetchone() - assert row is not None - total_outgoing: int = row["cnt"] + message_totals = await StatisticsRepository.get_database_message_totals() # Activity windows contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True) @@ -440,9 +450,9 @@ class StatisticsRepository: "total_packets": total_packets, "decrypted_packets": decrypted_packets, "undecrypted_packets": undecrypted_packets, - "total_dms": total_dms, - "total_channel_messages": total_channel_messages, - "total_outgoing": total_outgoing, + "total_dms": message_totals["total_dms"], + "total_channel_messages": message_totals["total_channel_messages"], + "total_outgoing": message_totals["total_outgoing"], "contacts_heard": contacts_heard, "repeaters_heard": repeaters_heard, "known_channels_active": known_channels_active, diff --git a/app/routers/debug.py b/app/routers/debug.py index 55c1f65..f7573e9 100644 --- a/app/routers/debug.py +++ b/app/routers/debug.py @@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe: async def debug_support_snapshot() -> DebugSnapshotResponse: """Return a support/debug snapshot with recent logs and live radio state.""" health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info) - statistics = await StatisticsRepository.get_all() + message_totals = await StatisticsRepository.get_database_message_totals() radio_probe = await _probe_radio() channels_with_incoming_messages = ( await MessageRepository.count_channels_with_incoming_messages() @@ -291,9 +291,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse: }, ), database=DebugDatabaseInfo( - total_dms=statistics["total_dms"], - total_channel_messages=statistics["total_channel_messages"], - total_outgoing=statistics["total_outgoing"], + total_dms=message_totals["total_dms"], + total_channel_messages=message_totals["total_channel_messages"], + total_outgoing=message_totals["total_outgoing"], ), radio_probe=radio_probe, logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)], diff --git a/app/routers/packets.py b/app/routers/packets.py index 4c6374c..40475e4 100644 --- a/app/routers/packets.py +++ b/app/routers/packets.py @@ -210,8 +210,7 @@ async def decrypt_historical_packets( except ValueError: raise _bad_request("Invalid hex string for contact public key") from None - packets = await RawPacketRepository.get_undecrypted_text_messages() - count = len(packets) + count = await RawPacketRepository.count_undecrypted_text_messages() if count == 0: return DecryptResult( started=False, diff --git a/tests/test_api.py b/tests/test_api.py index 574b968..4cf90f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -190,6 +190,29 @@ class TestDebugEndpoint: assert payload["database"]["total_channel_messages"] == 1 assert payload["database"]["total_outgoing"] == 1 + @pytest.mark.asyncio + async def test_support_snapshot_uses_lightweight_message_totals(self, test_db, client): + """Debug snapshot should not call the full statistics aggregation.""" + with ( + patch( + "app.routers.debug.StatisticsRepository.get_all", + new=AsyncMock(side_effect=AssertionError("get_all should not be called")), + ), + patch( + "app.routers.debug.StatisticsRepository.get_database_message_totals", + new=AsyncMock( + return_value={ + "total_dms": 0, + "total_channel_messages": 0, + "total_outgoing": 0, + } + ), + ), + ): + response = await client.get("/api/debug") + + assert response.status_code == 200 + class TestRadioDisconnectedHandler: """Test that RadioDisconnectedError maps to 503.""" diff --git a/tests/test_packets_router.py b/tests/test_packets_router.py index 339b5a5..d9d1243 100644 --- a/tests/test_packets_router.py +++ b/tests/test_packets_router.py @@ -5,7 +5,7 @@ undecrypted count endpoint, and the maintenance endpoint. """ import time -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -305,6 +305,43 @@ class TestDecryptHistoricalPackets: assert "key_type" in data["detail"].lower() +class TestUndecryptedTextPacketStreaming: + @pytest.mark.asyncio + async def test_count_undecrypted_text_messages_uses_batched_streaming(self, test_db): + """Counting undecrypted DM packets should stream batches and filter by payload type.""" + + class FakeCursor: + def __init__(self): + self._batches = [ + [ + {"id": 1, "data": b"\x09\x00dm", "timestamp": 1000}, + {"id": 2, "data": b"\x15\x00chan", "timestamp": 1001}, + ], + [{"id": 3, "data": b"\x09\x00dm2", "timestamp": 1002}], + [], + ] + self.fetchall_called = False + + async def fetchmany(self, size): + assert size > 0 + return self._batches.pop(0) + + async def close(self): + return None + + async def fetchall(self): + self.fetchall_called = True + raise AssertionError("fetchall() should not be used") + + fake_cursor = FakeCursor() + + with patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)): + count = await RawPacketRepository.count_undecrypted_text_messages(batch_size=2) + + assert fake_cursor.fetchall_called is False + assert count == 2 + + class TestRunHistoricalChannelDecryption: """Test the _run_historical_channel_decryption background task.""" diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 6a50531..79d5ff2 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -1,6 +1,7 @@ """Tests for the statistics repository and endpoint.""" import time +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -349,6 +350,52 @@ class TestPathHashWidthStats: assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) + @pytest.mark.asyncio + async def test_path_hash_width_scan_uses_batched_fetchmany(self, test_db): + """Hash-width stats should stream batches instead of calling fetchall().""" + + class FakeCursor: + def __init__(self): + self._batches = [ + [{"data": b"a"}, {"data": b"b"}], + [{"data": b"c"}], + [], + ] + self.fetchall_called = False + + async def fetchmany(self, size): + assert size > 0 + return self._batches.pop(0) + + async def fetchall(self): + self.fetchall_called = True + raise AssertionError("fetchall() should not be used") + + fake_cursor = FakeCursor() + + def fake_parse(raw_packet: bytes): + hash_sizes = { + b"a": 1, + b"b": 2, + b"c": 3, + } + hash_size = hash_sizes.get(raw_packet) + if hash_size is None: + return None + return SimpleNamespace(hash_size=hash_size) + + with ( + patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)), + patch("app.repository.settings.parse_packet_envelope", side_effect=fake_parse), + ): + breakdown = await StatisticsRepository._path_hash_width_24h() + + assert fake_cursor.fetchall_called is False + assert breakdown["total_packets"] == 3 + assert breakdown["single_byte"] == 1 + assert breakdown["double_byte"] == 1 + assert breakdown["triple_byte"] == 1 + class TestStatisticsEndpoint: @pytest.mark.asyncio From 4326f57977643a6605627238cc2916e4d04b1327 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:44:26 -0700 Subject: [PATCH 20/23] Lint fixes --- frontend/src/hooks/useUnreadCounts.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 0180901..be438c5 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -10,9 +10,11 @@ import { import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types'; import { takePrefetchOrFetch } from '../prefetch'; +type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' }; + function isUnreadTrackedConversation( conversation: Conversation | null -): conversation is Extract { +): conversation is UnreadTrackedConversation { return conversation?.type === 'channel' || conversation?.type === 'contact'; } From 88140081b94b66cb4d0d1ed8f89b125b17238ebb Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:54:32 -0700 Subject: [PATCH 21/23] Updating changelog + build for 3.6.3 --- CHANGELOG.md | 11 +++++++++++ LICENSES.md | 32 ++++++++++++++++++++++++++++++++ frontend/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e75979..a0dc948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [3.6.3] - 2026-03-30 + +Feature: Add multi-byte trace +Feature: Show node name on discovered node if we know it +Feature: Add docker installation script +Feature: Add historical noise floor to stats +Feature: Add trace tool +Bugfix: 100x performance on statistics endpoint with indices and better queries +Misc: Performance and correctness improvements for backend-of-the-frontend +Misc: Reorganize scripts + ## [3.6.2] - 2026-03-29 Feature: Be more flexible about timing and volume of full contact offload diff --git a/LICENSES.md b/LICENSES.md index 525a5aa..dac28f7 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -1625,6 +1625,37 @@ THE SOFTWARE. +### recharts (3.8.1) — MIT + +
+Full license text + +``` +The MIT License (MIT) + +Copyright (c) 2015-present recharts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +
+ ### sonner (2.0.7) — MIT
@@ -1748,3 +1779,4 @@ THE SOFTWARE. ```
+ diff --git a/frontend/package.json b/frontend/package.json index 36f6053..2edf5fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.2", + "version": "3.6.3", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index c4e8850..de26eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.6.2" +version = "3.6.3" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index f95cb93..ff2edd1 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.6.2" +version = "3.6.3" source = { virtual = "." } dependencies = [ { name = "aiomqtt" }, From 25df69bfbcdb08f9d6e5d74bacf8112c4c4ec3de Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 22:13:09 -0700 Subject: [PATCH 22/23] Add snakeoil certs to docker setup --- .gitignore | 1 + scripts/setup/install_docker.sh | 138 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/.gitignore b/.gitignore index 8935eab..e3ad722 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ references/ # local Docker compose files docker-compose.yml docker-compose.yaml +.docker-certs/ diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh index 168869d..012f573 100644 --- a/scripts/setup/install_docker.sh +++ b/scripts/setup/install_docker.sh @@ -21,6 +21,13 @@ NC='\033[0m' REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" COMPOSE_FILE="$REPO_DIR/docker-compose.yml" EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml" +SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs" +SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt" +SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key" +SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME" +SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME" +SNAKEOIL_CERT_CONTAINER_PATH="/app/certs/$SNAKEOIL_CERT_BASENAME" +SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME" IMAGE_MODE="image" TRANSPORT_MODE="serial" @@ -35,7 +42,9 @@ ENABLE_AUTH="N" AUTH_USERNAME="" AUTH_PASSWORD="" RUN_AS_HOST_USER="N" +ENABLE_SNAKEOIL_TLS="Y" BLE_MANUAL_WARNING=false +LOCAL_ACCESS_IP="" SERIAL_FOUND_HOST_PATHS=() SERIAL_FOUND_LABELS=() SERIAL_FOUND_DISPLAYS=() @@ -89,6 +98,89 @@ yaml_quote() { printf "'%s'" "$value" } +detect_primary_local_ip() { + local ip="" + local iface="" + + if command -v hostname &>/dev/null; then + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + fi + + if [ -z "$ip" ] && command -v ip &>/dev/null; then + ip="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')" + fi + + if [ -z "$ip" ] && command -v route &>/dev/null && command -v ipconfig &>/dev/null; then + iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" + if [ -n "$iface" ]; then + ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)" + fi + fi + + if [ -z "$ip" ]; then + ip="127.0.0.1" + fi + + printf '%s' "$ip" +} + +ensure_snakeoil_requirements() { + local dep + + for dep in openssl mktemp; do + if ! command -v "$dep" &>/dev/null; then + echo -e "${RED}Error: ${dep} is required to generate the snakeoil TLS certificate.${NC}" + exit 1 + fi + done +} + +generate_snakeoil_certificate() { + local san_ip="$1" + local tmp_config="" + + mkdir -p "$SNAKEOIL_CERT_DIR" + tmp_config="$(mktemp)" + + cat >"$tmp_config" <>"$tmp_config" + fi + + openssl req \ + -x509 \ + -nodes \ + -newkey rsa:2048 \ + -days 3650 \ + -keyout "$SNAKEOIL_KEY_HOST_PATH" \ + -out "$SNAKEOIL_CERT_HOST_PATH" \ + -config "$tmp_config" \ + -extensions v3_req >/dev/null 2>&1 + + rm -f "$tmp_config" + + chmod 600 "$SNAKEOIL_KEY_HOST_PATH" + chmod 644 "$SNAKEOIL_CERT_HOST_PATH" +} + echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}" echo echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}" @@ -266,6 +358,24 @@ else fi echo +echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}" +echo "Generating a local self-signed certificate enables HTTPS-only browser features" +echo "such as the channel key finder and, in some browsers, notifications." +echo "Browsers will still warn that the certificate is untrusted." +echo +read -r -p "Generate and enable a snakeoil TLS certificate? [Y/n]: " ENABLE_SNAKEOIL_TLS +ENABLE_SNAKEOIL_TLS="${ENABLE_SNAKEOIL_TLS:-Y}" +LOCAL_ACCESS_IP="$(detect_primary_local_ip)" +if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then + ensure_snakeoil_requirements + generate_snakeoil_certificate "$LOCAL_ACCESS_IP" + echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}" + echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}" +else + echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}" +fi +echo + if [ "$(uname -s)" = "Linux" ]; then echo -e "${BOLD}─── Container User ──────────────────────────────────────────────────${NC}" echo "The container runs as root by default for maximum serial compatibility." @@ -304,10 +414,28 @@ mkdir -p "$REPO_DIR/data" echo " - \"8000:8000\"" echo " volumes:" echo " - ./data:/app/data" + if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then + echo " - ./.docker-certs:/app/certs:ro" + fi if [ "$TRANSPORT_MODE" = "serial" ]; then echo " devices:" echo " - ${SERIAL_HOST_PATH}:${SERIAL_CONTAINER_PATH}" fi + if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then + echo " command:" + echo " - uv" + echo " - run" + echo " - uvicorn" + echo " - app.main:app" + echo " - --host" + echo " - 0.0.0.0" + echo " - --port" + echo " - \"8000\"" + echo " - --ssl-keyfile" + echo " - $SNAKEOIL_KEY_CONTAINER_PATH" + echo " - --ssl-certfile" + echo " - $SNAKEOIL_CERT_CONTAINER_PATH" + fi echo " environment:" echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")" if [ "$TRANSPORT_MODE" = "serial" ]; then @@ -354,3 +482,13 @@ echo echo -e "${PURPLE}┌──────────────────────────────────────────────┐${NC}" echo -e "${PURPLE}│ Run ${GREEN}${BOLD}docker compose up -d${NC}${PURPLE} to get started. │${NC}" echo -e "${PURPLE}└──────────────────────────────────────────────┘${NC}" +if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then + echo + echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}." + echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}" +else + echo + echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}." +fi +echo "If the interface does not appear, follow the logs with:" +echo " docker compose logs -f" From b1595e479ce51c45e04bc95c52c33155fc9d547f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 23:11:37 -0700 Subject: [PATCH 23/23] Use the image's full government name --- README.md | 2 +- docker-compose.example.yml | 2 +- scripts/setup/install_docker.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e503b0f..9fc8976 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ docker compose up -d The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace: ```yaml -image: jkingsman/remoteterm-meshcore:latest +image: docker.io/jkingsman/remoteterm-meshcore:latest ``` with: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 5b22a90..2fbeeb3 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,7 +1,7 @@ services: remoteterm: # build: . - image: jkingsman/remoteterm-meshcore:latest + image: docker.io/jkingsman/remoteterm-meshcore:latest # Optional on Linux: run container as your host user to avoid root-owned files in ./data # This is less reliable for serial-device access than running as root and may require diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh index 012f573..a6951f4 100644 --- a/scripts/setup/install_docker.sh +++ b/scripts/setup/install_docker.sh @@ -405,7 +405,7 @@ mkdir -p "$REPO_DIR/data" if [ "$IMAGE_MODE" = "build" ]; then echo " build: ." else - echo " image: jkingsman/remoteterm-meshcore:latest" + echo " image: docker.io/jkingsman/remoteterm-meshcore:latest" fi if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then echo " user: \"$(id -u):$(id -g)\""