Compare commits

...

17 Commits

Author SHA1 Message Date
Jack Kingsman 44f145b646 Updating changelog + build for 3.7.1 2026-04-02 18:01:22 -07:00
Jack Kingsman 55e2dc478d Redact Apprise URLs 2026-04-02 17:59:41 -07:00
Jack Kingsman 0932800e1f Fix lint 2026-04-02 17:38:35 -07:00
Jack Kingsman c333eb25e3 Updating changelog + build for 3.7.0 2026-04-02 17:30:19 -07:00
Jack Kingsman 580aa1cefd Correct TCP port 2026-04-02 13:55:05 -07:00
Jack Kingsman 30de09f71b Merge pull request #126 from maplemesh/gnomeadrift/repeater_telemetry_history
Logging battery voltage history from telemetry
2026-04-02 13:29:44 -07:00
Jack Kingsman 93d31adecd Don't change historical migrations (cruft from rebasing) and don't overwrite data 2026-04-02 13:21:21 -07:00
Jack Kingsman 5f969017f7 Add some tests, make it an actual endpoint (whoops said we didn't need that) and tidy things up a bit 2026-04-02 12:43:42 -07:00
Gnome Adrift 967dd05fad Prune telemetry entries, remove uplot comments, format code 2026-04-02 12:34:00 -07:00
Gnome Adrift c808f0930b Remove automatic telemetry querying, remove battery pane, add telemetry history pane 2026-04-02 12:31:51 -07:00
Gnome Adrift 87df4b4aa1 Fix for telemetry polling 2026-04-02 12:27:18 -07:00
Gnome Adrift 0511d6f69b Make battery history update when fetching telemetry 2026-04-02 12:27:18 -07:00
Gnome Adrift 78b5598f67 First draft of repeater telemetry feature 2026-04-02 12:27:06 -07:00
Jack Kingsman 5e1bdb2cc1 Fix terminals on hash room parsing 2026-04-02 00:23:07 -07:00
Jack Kingsman 4420d44838 Add bulk room add 2026-04-02 00:19:25 -07:00
Jack Kingsman ead1774cd3 Boost sidebar icon color 2026-04-01 22:17:04 -07:00
Jack Kingsman 0d45cbd849 Yolo to FK pragma
Move to fk pragma
2026-04-01 22:09:00 -07:00
43 changed files with 1655 additions and 170 deletions
+1 -1
View File
@@ -463,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
+20
View File
@@ -1,3 +1,23 @@
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking
* Feature: Repeater info pane just like contacts
* Feature: Make repeaters blockable
* Feature: Add new-node advert blocking
* Feature: Add bulk deletion interface
* Feature: Bulk room add on alt+click of new channel button
* Feature: More info in debug endpoint
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
* Misc: Default auto-DM decrypt to true
* Misc: Reorganize some settings panes
* Misc: Enable FK pragma
* Misc: Various performance and correctness fixes
* Misc: Correct TCP default port
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
+2 -2
View File
@@ -177,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
@@ -193,7 +193,7 @@ Common launch patterns:
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
+2 -1
View File
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
tcp_host: str = ""
tcp_port: int = 4000
tcp_port: int = 5000
ble_address: str = ""
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
+29
View File
@@ -382,6 +382,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 49)
applied += 1
# Migration 50: Repeater telemetry history table + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3099,3 +3106,25 @@ async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history (public_key, timestamp)
"""
)
await conn.commit()
+8
View File
@@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
class RepeaterNodeInfoResponse(BaseModel):
@@ -921,3 +924,8 @@ class StatisticsResponse(BaseModel):
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
noise_floor_24h: NoiseFloorHistoryStats
class TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
+2
View File
@@ -8,6 +8,7 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [
@@ -20,5 +21,6 @@ __all__ = [
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository",
]
+75
View File
@@ -0,0 +1,75 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per repeater (sanity cap)
_MAX_ENTRIES_PER_REPEATER = 1000
class RepeaterTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
await db.conn.execute(
"""
INSERT INTO repeater_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
)
# Prune entries older than 30 days
cutoff = int(time.time()) - _MAX_AGE_SECONDS
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
)
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
await db.conn.execute(
"""
DELETE FROM repeater_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM repeater_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
)
await db.conn.commit()
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
cursor = await db.conn.execute(
"""
SELECT timestamp, data
FROM repeater_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
)
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
+224 -47
View File
@@ -1,7 +1,8 @@
import logging
import re
from hashlib import sha256
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from pydantic import BaseModel, Field
from app.channel_constants import (
@@ -10,10 +11,12 @@ from app.channel_constants import (
is_public_channel_key,
is_public_channel_name,
)
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -31,12 +34,154 @@ class CreateChannelRequest(BaseModel):
)
class BulkCreateHashtagChannelsRequest(BaseModel):
channel_names: list[str] = Field(
min_length=1,
description="List of hashtag room names. Leading # is optional per entry.",
)
try_historical: bool = Field(
default=False,
description="Attempt one background historical decrypt sweep for the newly added rooms.",
)
class BulkCreateHashtagChannelsResponse(BaseModel):
created_channels: list[Channel]
existing_count: int
invalid_names: list[str]
decrypt_started: bool = False
decrypt_total_packets: int = 0
message: str
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
)
def _derive_channel_identity(
requested_name: str,
request_key: str | None = None,
) -> tuple[str, str, bool]:
is_hashtag = requested_name.startswith("#")
if is_public_channel_name(requested_name):
if request_key:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
if request_key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
return key_hex, requested_name, False
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
return key_bytes.hex().upper(), requested_name, is_hashtag
def _normalize_bulk_hashtag_name(name: str) -> str | None:
trimmed = name.strip()
if not trimmed:
return None
normalized = trimmed.lstrip("#").strip()
if not normalized:
return None
if len(normalized) > 31:
return None
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
return None
return f"#{normalized}"
async def _run_historical_channel_decryption_for_channels(
channels: list[tuple[bytes, str, str]],
) -> None:
packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
decrypted_count = 0
matched_channel_names: set[str] = set()
if total == 0:
logger.info("No undecrypted packets to process for bulk channel decrypt")
return
logger.info(
"Starting bulk historical channel decryption of %d packets across %d channels",
total,
len(channels),
)
for packet_id, packet_data, packet_timestamp in packets:
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None
for channel_key_bytes, channel_key_hex, channel_name in channels:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is None:
continue
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=channel_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
path_len=path_len,
realtime=False,
)
if msg_id is not None:
decrypted_count += 1
matched_channel_names.add(channel_name)
break
logger.info(
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
decrypted_count,
total,
len(matched_channel_names),
)
if decrypted_count > 0:
broadcast_success(
"Bulk historical decrypt complete",
(
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
f"across {len(matched_channel_names)} room"
f"{'s' if len(matched_channel_names) != 1 else ''}"
),
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
@@ -69,50 +214,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
automatically when sending a message (see messages.py send_channel_message).
"""
requested_name = request.name
is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -132,6 +234,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
async def bulk_create_hashtag_channels(
request: BulkCreateHashtagChannelsRequest,
background_tasks: BackgroundTasks,
response: Response,
) -> BulkCreateHashtagChannelsResponse:
created_channels: list[Channel] = []
existing_count = 0
invalid_names: list[str] = []
decrypt_started = False
decrypt_total_packets = 0
decrypt_targets: list[tuple[bytes, str, str]] = []
for raw_name in request.channel_names:
normalized_name = _normalize_bulk_hashtag_name(raw_name)
if normalized_name is None:
invalid_names.append(raw_name)
continue
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
existing = await ChannelRepository.get_by_key(key_hex)
if existing is not None:
existing_count += 1
continue
await ChannelRepository.upsert(
key=key_hex,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(
status_code=500,
detail="Channel was created but could not be reloaded",
)
created_channels.append(stored)
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
_broadcast_channel_update(stored)
if request.try_historical and decrypt_targets:
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
if decrypt_total_packets > 0:
background_tasks.add_task(
_run_historical_channel_decryption_for_channels, decrypt_targets
)
decrypt_started = True
response.status_code = status.HTTP_202_ACCEPTED
message = (
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
if created_channels
else "No new rooms were added"
)
if request.try_historical and decrypt_targets:
if decrypt_started:
message += (
f" and started background decrypt of {decrypt_total_packets} packet"
f"{'s' if decrypt_total_packets != 1 else ''}"
)
else:
message += "; no undecrypted packets were available"
return BulkCreateHashtagChannelsResponse(
created_channels=created_channels,
existing_count=existing_count,
invalid_names=invalid_names,
decrypt_started=decrypt_started,
decrypt_total_packets=decrypt_total_packets,
message=message,
)
@router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp)."""
+40 -2
View File
@@ -1,4 +1,5 @@
import logging
import time
from fastapi import APIRouter, HTTPException
@@ -21,8 +22,9 @@ from app.models import (
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
)
from app.repository import ContactRepository
from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
batch_cli_fetch,
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
return RepeaterStatusResponse(
response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
@@ -128,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0),
)
# Record to telemetry history as a JSON blob (best-effort)
now = int(time.time())
status_dict = response.model_dump(exclude={"telemetry_history"})
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=now,
data=status_dict,
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
# Fetch recent history and embed in response
try:
since = now - 30 * 86400 # last 30 days
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
except Exception as e:
logger.warning("Failed to fetch telemetry history: %s", e)
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=list[TelemetryHistoryEntry],
)
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
since = int(time.time()) - 30 * 86400
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
return [TelemetryHistoryEntry(**row) for row in rows]
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
+28 -20
View File
@@ -204,35 +204,43 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
from app.config import settings as app_settings_config
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
if app_settings_config.skip_post_connect_sync:
logger.info(
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
)
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
radio_manager._release_operation_lock("post_connect_setup")
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
if not app_settings_config.skip_post_connect_sync:
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
radio_manager._setup_complete = True
finally:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.2",
"version": "3.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.2",
"version": "3.6.3",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.6.7",
"version": "3.7.1",
"type": "module",
"scripts": {
"dev": "vite",
+35 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
@@ -85,6 +85,8 @@ export function App() {
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -190,6 +192,7 @@ export function App() {
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
} = useContactsAndChannels({
@@ -421,16 +424,25 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleOpenNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
openNewMessageModal();
}, [openNewMessageModal]);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(event?.altKey === true);
openNewMessageModal();
},
[openNewMessageModal]
);
const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(false);
closeNewMessageModal();
}, [closeNewMessageModal]);
const handleCloseBulkAddResults = useCallback(() => {
setBulkAddResult(null);
}, []);
const handleChannelReferenceClick = useCallback(
(channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName);
@@ -444,11 +456,20 @@ export function App() {
hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1,
}));
setShowBulkAddChannelTab(false);
openNewMessageModal();
},
[channels, handleNavigateToChannel, openNewMessageModal]
);
const handleBulkAddChannels = useCallback(
async (channelNames: string[], tryHistorical: boolean) => {
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
setBulkAddResult(result);
},
[handleBulkCreateHashtagChannels]
);
const statusProps = {
health,
config,
@@ -474,6 +495,9 @@ export function App() {
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
};
const conversationPaneProps = {
activeConversation,
contacts,
@@ -570,10 +594,12 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
};
const contactInfoPaneProps = {
contactKey: infoPaneContactKey,
@@ -637,6 +663,7 @@ export function App() {
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
@@ -647,6 +674,7 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
@@ -655,6 +683,7 @@ export function App() {
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
+9
View File
@@ -1,6 +1,7 @@
import type {
AppSettings,
AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel,
ChannelDetail,
CommandResponse,
@@ -34,6 +35,7 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -190,6 +192,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
method: 'POST',
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -408,6 +415,8 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
+16
View File
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps {
localLabel: LocalLabel;
showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
@@ -50,6 +56,7 @@ interface AppShellProps {
onToggleSettingsView: () => void;
onCloseSettingsView: () => void;
onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps;
@@ -61,6 +68,7 @@ interface AppShellProps {
>;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
@@ -68,6 +76,7 @@ interface AppShellProps {
export function AppShell({
localLabel,
showNewMessage,
showBulkAddResults,
showSettings,
settingsSection,
sidebarOpen,
@@ -79,6 +88,7 @@ export function AppShell({
onToggleSettingsView,
onCloseSettingsView,
onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange,
statusProps,
sidebarProps,
@@ -87,6 +97,7 @@ export function AppShell({
settingsProps,
crackerProps,
newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
@@ -306,6 +317,11 @@ export function AppShell({
open={showNewMessage}
onClose={onCloseNewMessage}
/>
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
@@ -0,0 +1,101 @@
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
import { getConversationHash } from '../utils/urlHash';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface BulkAddChannelResultModalProps {
open: boolean;
result: BulkCreateHashtagChannelsResult | null;
onClose: () => void;
}
function getChannelHref(channel: Channel): string {
const hash = getConversationHash({
type: 'channel',
id: channel.key,
name: channel.name,
});
if (typeof window === 'undefined') {
return hash;
}
return `${window.location.origin}${window.location.pathname}${hash}`;
}
export function BulkAddChannelResultModal({
open,
result,
onClose,
}: BulkAddChannelResultModalProps) {
const createdChannels = result?.created_channels ?? [];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Bulk Add Complete</DialogTitle>
<DialogDescription>
{result?.message ?? 'Review the newly added rooms below.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{result && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
<div className="mt-1 font-medium">{createdChannels.length}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Already Present
</div>
<div className="mt-1 font-medium">{result.existing_count}</div>
</div>
</div>
)}
{createdChannels.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Ctrl+click any room to open it in a new tab.
</p>
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
<ul className="divide-y divide-border/70">
{createdChannels.map((channel) => (
<li key={channel.key}>
<a
href={getChannelHref(channel)}
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
>
{channel.name}
</a>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
)}
{result && result.invalid_names.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Ignored invalid room names: {result.invalid_names.join(', ')}
</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+166 -41
View File
@@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
@@ -29,53 +35,121 @@ interface NewMessageModalProps {
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
function validateHashtagName(channelName: string): string | null {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
}
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
const tokens = rawText
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean);
const invalidNames: string[] = [];
const channelNames: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const stripped = token.replace(/^#+/, '');
const validationError = validateHashtagName(stripped);
if (validationError) {
invalidNames.push(token);
continue;
}
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
const channelName = `#${normalized}`;
if (seen.has(channelName)) {
continue;
}
seen.add(channelName);
channelNames.push(channelName);
}
return { channelNames, invalidNames };
}
export function NewMessageModal({
open,
undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
useEffect(() => {
if (!open || !prefillRequest) {
if (!open) {
return;
}
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
}, [open, prefillRequest]);
if (prefillRequest) {
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
return;
}
if (showBulkAddChannelTab) {
setTab('bulk-hashtag');
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
bulkTextareaRef.current?.focus();
});
return;
}
setTab('new-contact');
}, [open, prefillRequest, showBulkAddChannelTab]);
const handleCreate = async () => {
setError('');
@@ -87,7 +161,6 @@ export function NewMessageModal({
setError('Name and public key are required');
return;
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
@@ -102,10 +175,24 @@ export function NewMessageModal({
setError(validationError);
return;
}
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
} else {
const { channelNames, invalidNames } = parseBulkHashtagNames(
bulkChannelText,
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
@@ -118,16 +205,6 @@ export function NewMessageModal({
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
@@ -139,7 +216,6 @@ export function NewMessageModal({
setLoading(true);
try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName('');
@@ -166,28 +242,36 @@ export function NewMessageModal({
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as Tab);
onValueChange={(value) => {
setTab(value as Tab);
resetForm();
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3">
<TabsList
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -239,7 +323,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
setChannelKey(hex);
}}
@@ -268,20 +352,55 @@ export function NewMessageModal({
</div>
</div>
<div className="mt-3 space-y-1">
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="text-xs text-muted-foreground pl-7">
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
{showBulkAddChannelTab && (
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
<textarea
ref={bulkTextareaRef}
id="bulk-hashtag-names"
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
)}
</Tabs>
{showHistoricalOption && (
@@ -289,7 +408,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
className="cursor-pointer text-sm text-muted-foreground"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''}
@@ -301,7 +420,7 @@ export function NewMessageModal({
/>
</div>
{tryHistorical && (
<p className="text-xs text-muted-foreground text-right">
<p className="text-right text-xs text-muted-foreground">
Messages will stream in as they decrypt in the background
</p>
)}
@@ -330,7 +449,13 @@ export function NewMessageModal({
</Button>
)}
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
{loading
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
</Button>
</DialogFooter>
</DialogContent>
+46 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -90,7 +98,40 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
const telemetryHistoryRequestRef = useRef(0);
useEffect(() => {
telemetryHistoryRequestRef.current += 1;
telemetryHistorySourceRef.current = 'none';
setTelemetryHistory([]);
if (!loggedIn) return;
const requestId = telemetryHistoryRequestRef.current;
api
.repeaterTelemetryHistory(conversation.id)
.then((history) => {
if (telemetryHistoryRequestRef.current !== requestId) return;
if (telemetryHistorySourceRef.current === 'live') return;
telemetryHistorySourceRef.current = 'preload';
setTelemetryHistory(history);
})
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (!liveHistory) return;
telemetryHistorySourceRef.current = 'live';
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
@@ -353,6 +394,9 @@ export function RepeaterDashboard({
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} />
</div>
)}
</div>
+5 -4
View File
@@ -97,7 +97,7 @@ interface SidebarProps {
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: () => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -659,8 +659,9 @@ export function Sidebar({
}) => (
<div
key={key}
data-active={active ? 'true' : undefined}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary'
)}
role="button"
@@ -669,10 +670,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
<span className="sidebar-tool-icon" aria-hidden="true">
{icon}
</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
</div>
);
@@ -0,0 +1,167 @@
import { useState, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
};
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).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatUptime(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
const [metric, setMetric] = useState<Metric>('battery_volts');
const config = METRIC_CONFIG[metric];
const chartData = useMemo(() => {
return entries.map((e) => {
const d = e.data;
return {
timestamp: e.timestamp,
battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received,
packets_sent: d.packets_sent,
uptime_seconds: d.uptime_seconds,
};
});
}, [entries]);
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Telemetry History</h3>
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
</div>
<div className="p-3">
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors',
metric === m
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{METRIC_CONFIG[m].label}
</button>
))}
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch status above to record data points.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={formatTime}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(ts) => formatTime(Number(ts))}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value);
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix =
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
const label =
metric === 'packets'
? name === 'packets_received'
? 'Received'
: 'Sent'
: config.label;
return [`${display}${suffix}`, label];
}}
/>
{dataKeys.map((key, i) => (
<Area
key={key}
type="linear"
dataKey={key}
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
@@ -643,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
}
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
function censorAppriseUrl(url: string): string {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
const targets = (urls || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (targets.length === 0) return 'No targets configured';
const joined = targets.join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
return targets.map(censorAppriseUrl).join(', ');
}
function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -347,17 +347,20 @@ function PreviewSidebarRow({
}) {
return (
<div
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
<span className="sidebar-tool-icon" aria-hidden="true">
{leading}
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{label}
</span>
{badge}
{!badge && (
<span className="text-muted-foreground" aria-hidden="true">
<span className="sidebar-tool-icon" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
+20 -1
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { Channel, Contact, Conversation } from '../types';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation]
);
const handleBulkCreateHashtagChannels = useCallback(
async (
channelNames: string[],
tryHistorical: boolean
): Promise<BulkCreateHashtagChannelsResult> => {
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
const data = await api.getChannels();
setChannels(data);
if (tryHistorical && result.decrypt_started) {
fetchUndecryptedCountInternal();
}
return result;
},
[fetchUndecryptedCountInternal]
);
const handleDeleteChannel = useCallback(
async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
};
+52
View File
@@ -56,6 +56,14 @@
--badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground);
/* Sidebar navigation accents */
--sidebar-icon-color: hsl(var(--foreground));
--sidebar-icon-hover-color: hsl(var(--foreground));
--sidebar-icon-active-color: hsl(var(--foreground));
--sidebar-label-color: hsl(var(--muted-foreground));
--sidebar-label-hover-color: hsl(var(--foreground));
--sidebar-label-active-color: hsl(var(--foreground));
/* Error toast */
--toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%;
@@ -126,6 +134,50 @@
animation: message-highlight 2s ease-out forwards;
}
.sidebar-tool-icon {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
color: var(--sidebar-icon-color);
opacity: 1;
transition:
color 150ms ease,
opacity 150ms ease;
}
.sidebar-tool-icon svg {
stroke-width: 2.35;
}
.sidebar-tool-label {
color: var(--sidebar-label-color);
transition: color 150ms ease;
}
.sidebar-action-row:hover .sidebar-tool-icon,
.sidebar-action-row:focus-visible .sidebar-tool-icon {
color: var(--sidebar-icon-hover-color);
opacity: 1;
}
.sidebar-action-row:hover .sidebar-tool-label,
.sidebar-action-row:focus-visible .sidebar-tool-label {
color: var(--sidebar-label-hover-color);
}
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
color: var(--sidebar-icon-active-color);
opacity: 1;
}
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
color: var(--sidebar-label-active-color);
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
describe('BulkAddChannelResultModal', () => {
it('renders links only for newly created rooms', () => {
render(
<BulkAddChannelResultModal
open
onClose={() => {}}
result={{
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
{
key: 'BB'.repeat(16),
name: '#mesh-room',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 3,
invalid_names: ['bad_room'],
decrypt_started: true,
decrypt_total_packets: 8,
message: 'Created 2 rooms',
}}
/>
);
const opsLink = screen.getByRole('link', { name: '#ops' });
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
expect(opsLink.getAttribute('href')).toContain('#channel/');
expect(meshLink.getAttribute('href')).toContain('#channel/');
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
});
});
+26
View File
@@ -169,6 +169,32 @@ describe('MessageList channel sender rendering', () => {
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references when followed by clause punctuation', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
await user.click(screen.getByRole('button', { name: '#ops-room' }));
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
+15 -5
View File
@@ -122,11 +122,21 @@ describe('linked channel references', () => {
]);
});
it('ignores invalid or embedded channel-like text', () => {
it('finds linked channel references terminated by clause punctuation', () => {
expect(
findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
).toEqual([]);
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 22, end: 27 },
{ label: '#alpha-room', start: 37, end: 48 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
const references = findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
);
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
});
});
@@ -27,6 +27,7 @@ describe('NewMessageModal form reset', () => {
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
@@ -44,6 +45,7 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
{...overrides}
/>
);
@@ -111,6 +113,53 @@ describe('NewMessageModal form reset', () => {
});
});
describe('bulk hashtag tab', () => {
it('is only visible when enabled', () => {
renderModal();
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
});
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'#Ops{enter}mesh-room another-room #Ops'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
await waitFor(() => {
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
['#ops', '#mesh-room', '#another-room'],
false
);
});
expect(onClose).toHaveBeenCalled();
});
it('shows invalid bulk room names before submitting', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'good-room bad_room'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
});
});
describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (TelemetryHistoryPane fetches on mount)
vi.mock('../api', () => ({
api: {
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
@@ -118,6 +126,16 @@ const defaultProps = {
onDeleteContact: vi.fn(),
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -418,6 +436,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [],
};
render(<RepeaterDashboard {...defaultProps} />);
@@ -634,4 +653,106 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore();
});
});
describe('telemetry history', () => {
beforeEach(async () => {
const { api } = await import('../api');
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
});
it('loads telemetry history on mount when logged in', async () => {
const { api } = await import('../api');
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
});
});
it('shows telemetry history pane in logged-in view even before status fetch', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
historySpy.mockResolvedValue([]);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [liveEntry],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
it('does not let an older preload overwrite newer live status history', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
historySpy.mockReturnValue(deferred.promise);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
await deferred.promise;
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
});
+16
View File
@@ -7,3 +7,19 @@ class ResizeObserver {
}
globalThis.ResizeObserver = ResizeObserver;
// Several components call matchMedia at import time for responsive detection
if (typeof globalThis.matchMedia === 'undefined') {
Object.defineProperty(globalThis, 'matchMedia', {
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
import type { Contact } from '../types';
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
// Mock api module
vi.mock('../api', () => ({
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
getChannels: vi.fn(),
createContact: vi.fn(),
createChannel: vi.fn(),
bulkCreateHashtagChannels: vi.fn(),
deleteContact: vi.fn(),
deleteChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(),
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
expect(api.getContacts).toHaveBeenCalledTimes(2);
});
});
describe('bulk hashtag creation', () => {
it('refreshes channels and returns the backend result', async () => {
const { api } = await import('../api');
const resultPayload: BulkCreateHashtagChannelsResult = {
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 1,
invalid_names: [],
decrypt_started: true,
decrypt_total_packets: 12,
message: 'Created 1 room',
};
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
const { result } = renderUseContactsAndChannels();
let response: BulkCreateHashtagChannelsResult | null = null;
await act(async () => {
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
});
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
expect(api.getChannels).toHaveBeenCalled();
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
expect(response).toEqual(resultPayload);
});
});
});
-4
View File
@@ -49,10 +49,6 @@
--overlay: 220 20% 10%;
}
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Windows 95 ───────────────────────────────────────────── */
:root[data-theme='windows-95'] {
--background: 180 100% 25%;
+15
View File
@@ -235,6 +235,15 @@ export interface ChannelTopSender {
message_count: number;
}
export interface BulkCreateHashtagChannelsResult {
created_channels: Channel[];
existing_count: number;
invalid_names: string[];
decrypt_started: boolean;
decrypt_total_packets: number;
message: string;
}
export interface ChannelDetail {
channel: Channel;
message_counts: ChannelMessageCounts;
@@ -407,6 +416,7 @@ export interface RepeaterStatusResponse {
flood_dups: number;
direct_dups: number;
full_events: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface RepeaterNeighborsResponse {
@@ -470,6 +480,11 @@ export interface PaneState {
fetched_at?: number | null;
}
export interface TelemetryHistoryEntry {
timestamp: number;
data: Record<string, number>;
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;
+1 -1
View File
@@ -3,7 +3,7 @@
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
+1 -1
View File
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
}
// Generate URL hash from conversation
function getConversationHash(conv: Conversation | null): string {
export function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.6.7"
version = "3.7.1"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
+1
View File
@@ -63,6 +63,7 @@ export default defineConfig({
timeout: 180_000,
env: {
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
MESHCORE_SKIP_POST_CONNECT_SYNC: 'true',
// Pass through the serial port from the environment
...(process.env.MESHCORE_SERIAL_PORT
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
+51 -1
View File
@@ -1,7 +1,8 @@
"""Tests for the channels router endpoints."""
import time
from unittest.mock import patch
from hashlib import sha256
from unittest.mock import AsyncMock, patch
import pytest
@@ -77,6 +78,55 @@ class TestCreateChannel:
assert channel is not None
assert channel.flood_scope_override is None
@pytest.mark.asyncio
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
"try_historical": False,
},
)
assert response.status_code == 200
data = response.json()
assert [channel["name"] for channel in data["created_channels"]] == [
"#mesh-room",
"#another-room",
]
assert data["existing_count"] == 2
assert data["invalid_names"] == ["bad_room"]
assert data["decrypt_started"] is False
@pytest.mark.asyncio
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
with (
patch(
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
new=AsyncMock(return_value=7),
),
patch(
"app.routers.channels._run_historical_channel_decryption_for_channels",
new=AsyncMock(),
) as mock_decrypt,
):
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["ops", "mesh-room"],
"try_historical": True,
},
)
assert response.status_code == 202
data = response.json()
assert data["decrypt_started"] is True
assert data["decrypt_total_packets"] == 7
mock_decrypt.assert_awaited_once()
class TestPublicChannelProtection:
@pytest.mark.asyncio
+1 -1
View File
@@ -33,7 +33,7 @@ class TestTransportExclusivity:
def test_tcp_default_port(self):
s = Settings(tcp_host="192.168.1.1")
assert s.tcp_port == 4000
assert s.tcp_port == 5000
def test_ble_only(self):
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
+16 -16
View File
@@ -1249,8 +1249,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 49
assert applied == 12
assert await get_version(conn) == 50
cursor = await conn.execute(
"""
@@ -1321,8 +1321,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 49
assert applied == 12
assert await get_version(conn) == 50
cursor = await conn.execute(
"""
@@ -1388,8 +1388,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 49
assert applied == 6
assert await get_version(conn) == 50
cursor = await conn.execute(
"""
@@ -1441,8 +1441,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 49
assert applied == 11
assert await get_version(conn) == 50
await conn.execute(
"""
@@ -1503,8 +1503,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 49
assert applied == 10
assert await get_version(conn) == 50
await conn.execute(
"""
@@ -1556,8 +1556,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 49
assert applied == 9
assert await get_version(conn) == 50
await conn.execute(
"""
@@ -1696,8 +1696,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 49
assert applied == 5
assert await get_version(conn) == 50
cursor = await conn.execute(
"""
@@ -1790,8 +1790,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 49
assert applied == 4
assert await get_version(conn) == 50
cursor = await conn.execute(
"""
+189
View File
@@ -0,0 +1,189 @@
"""Tests for repeater telemetry history: repository CRUD and embedded status response."""
import time
import pytest
from app.models import CONTACT_TYPE_REPEATER
from app.repository import (
ContactRepository,
RepeaterTelemetryRepository,
)
KEY_A = "aa" * 32
KEY_B = "bb" * 32
SAMPLE_STATUS = {
"battery_volts": 4.15,
"tx_queue_len": 0,
"noise_floor_dbm": -100,
"last_rssi_dbm": -80,
"last_snr_db": 5.0,
"packets_received": 100,
"packets_sent": 50,
"airtime_seconds": 300,
"rx_airtime_seconds": 200,
"uptime_seconds": 1000,
"sent_flood": 10,
"sent_direct": 40,
"recv_flood": 60,
"recv_direct": 40,
"flood_dups": 5,
"direct_dups": 2,
"full_events": 0,
}
async def _insert_repeater(public_key: str, name: str = "Repeater"):
"""Insert a repeater contact into the test database."""
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"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,
"first_seen": None,
}
)
@pytest.fixture
async def _db(test_db):
"""Set up test DB and patch the repeater_telemetry module's db reference."""
from app.repository import repeater_telemetry
original = repeater_telemetry.db
repeater_telemetry.db = test_db
try:
yield test_db
finally:
repeater_telemetry.db = original
class TestRepeaterTelemetryRepository:
"""Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage."""
@pytest.mark.asyncio
async def test_record_and_get_history(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now - 3600,
data={**SAMPLE_STATUS, "battery_volts": 4.15},
)
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now,
data={**SAMPLE_STATUS, "battery_volts": 4.10},
)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
assert len(history) == 2
assert history[0]["data"]["battery_volts"] == 4.15
assert history[1]["data"]["battery_volts"] == 4.10
assert history[0]["timestamp"] < history[1]["timestamp"]
@pytest.mark.asyncio
async def test_get_history_filters_by_time(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS)
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS)
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
assert len(history) == 2
@pytest.mark.asyncio
async def test_get_history_isolates_by_key(self, _db):
await _insert_repeater(KEY_A)
await _insert_repeater(KEY_B)
now = int(time.time())
await RepeaterTelemetryRepository.record(
KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1}
)
await RepeaterTelemetryRepository.record(
KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9}
)
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]["data"]["battery_volts"] == 4.1
@pytest.mark.asyncio
async def test_data_stored_as_json(self, _db):
"""Verify the data column stores valid JSON that round-trips correctly."""
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
assert len(history) == 1
assert history[0]["data"] == SAMPLE_STATUS
class TestTelemetryHistoryEndpoint:
"""Tests for the read-only GET telemetry-history endpoint."""
@pytest.mark.asyncio
async def test_returns_history_for_repeater(self, _db, client):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["data"]["battery_volts"] == 4.15
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_history(self, _db, client):
await _insert_repeater(KEY_A)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_rejects_non_repeater(self, _db, client):
await ContactRepository.upsert(
{
"public_key": KEY_A,
"name": "Node",
"type": 0,
"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,
"first_seen": None,
}
)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_returns_404_for_unknown_contact(self, _db, client):
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 404
Generated
+1 -1
View File
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.6.7"
version = "3.7.1"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },