20 Commits

Author SHA1 Message Date
Jack Kingsman
3580aeda5a Updating changelog + build for 3.6.0 2026-03-22 22:14:55 -07:00
Jack Kingsman
bb97b983bb Add room activity to stats view 2026-03-22 22:13:40 -07:00
Jack Kingsman
da31b67d54 Add on-receive packet analyzer for canonical copy. Closes #97. 2026-03-22 21:34:41 -07:00
Jack Kingsman
d840159f9c Update meshcore_py and remove monkeypatch for serial frame start detection. 2026-03-22 11:06:24 -07:00
Jack Kingsman
9de4158a6c Monkeypatch the meshcore_py lib for frame-start handling 2026-03-21 22:46:59 -07:00
Jack Kingsman
1e21644d74 Swap repeaters and room servers for better ordering, and the less common contact type at the bottom 2026-03-21 13:15:18 -07:00
Jack Kingsman
df0ed8452b Add BYOPacket analyzer. Closes #98. 2026-03-20 21:57:07 -07:00
Jack Kingsman
d4a5f0f728 Scroll in room server control pane. Closes #99. 2026-03-20 19:43:55 -07:00
Jack Kingsman
3e2c48457d Be more compact about the room server controls 2026-03-20 18:16:29 -07:00
Jack Kingsman
d4f518df0c Retry e2e tests one before failing 2026-03-19 21:57:03 -07:00
Jack Kingsman
5213c8c84c Updating changelog + build for 3.5.0 2026-03-19 21:53:45 -07:00
Jack Kingsman
33c2b0c948 Be better about identity resolution for stats view 2026-03-19 21:42:39 -07:00
Jack Kingsman
b021a4a8ac Fix e2e tests for contact stuff 2026-03-19 21:39:18 -07:00
Jack Kingsman
c74fdec10b Add database information to debug endpoint 2026-03-19 21:17:23 -07:00
Jack Kingsman
cf314e02ff Be cleaner about message cache dedupe after trimming inactive convos 2026-03-19 21:03:20 -07:00
Jack Kingsman
8ae600d010 Docs updates 2026-03-19 20:58:49 -07:00
Jack Kingsman
fdd82e1f77 Clean up orphaned contact child rows and add foreign key enforcement 2026-03-19 20:56:36 -07:00
Jack Kingsman
9d129260fd Fix up the header collapse to be less terrible 2026-03-19 20:53:24 -07:00
Jack Kingsman
2b80760696 Add DB entry for outgoing inside the radio lock (didn't we just do the opposite?) 2026-03-19 20:43:35 -07:00
Jack Kingsman
c2655c1809 Add basic room server support. Closes #78.
Allow basic room server usage
2026-03-19 20:28:44 -07:00
55 changed files with 1889 additions and 459 deletions

View File

@@ -165,7 +165,8 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- Non-final attempts use the effective route (`override > direct > flood`).
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
- Non-final retry attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
@@ -174,7 +175,7 @@ Direct-message send behavior intentionally mirrors the firmware/library `send_ms
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
@@ -324,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
@@ -348,7 +350,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| 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 |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
@@ -398,7 +400,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Bulk update via `POST /api/read-state/mark-all-read`
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`

View File

@@ -1,3 +1,36 @@
## [3.6.0] - 2026-03-22
Feature: Add incoming-packet analytics
Feature: BYOPacket for analysis
Feature: Add room activity to stats view
Bugfix: Handle Heltec v3 serial noise
Misc: Swap repeaters and room servers for better ordering
## [3.5.0] - 2026-03-19
Feature: Add room server alpha support
Feature: Add option to force-reset node clock when it's too far ahead
Feature: DMs auto-retry before resorting to flood
Feature: Add impulse zero-hop advert
Feature: Utilize PATH packets to correctly source a contact's route
Feature: Metrics view on raw packet pane
Feature: Metric, Imperial, and Smoots are now selectable for distance display
Feature: Allow favorites to be sorted
Feature: Add multi-ack support
Feature: Password-remember checkbox on repeaters + room servers
Bugfix: Serialize radio disconnect in a lock
Bugfix: Fix contact bar layout issues
Bugfix: Fix sidebar ordering for contacts by advert recency
Bugfix: Fix version reporting in community MQTT
Bugfix: Fix Apprise duplicate names
Bugfix: Be better about identity resolution in the stats pane
Misc: Docs, test, and performance enhancements
Misc: Don't prompt "Are you sure" when leaving an unedited interation
Misc: Log node time on startup
Misc: Improve community MQTT error bubble-up
Misc: Unread DMs always have a red unread counter
Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16
Bugfix: Improve handling of version information on prebuilt bundles

View File

@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
</details>
### meshcore (2.2.29) — MIT
### meshcore (2.3.2) — MIT
<details>
<summary>Full license text</summary>

View File

@@ -113,16 +113,16 @@ app/
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
### DM ingest + ACKs
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
@@ -188,6 +188,7 @@ app/
- `POST /contacts/{public_key}/command`
- `POST /contacts/{public_key}/routing-override`
- `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
- `POST /contacts/{public_key}/repeater/login`
- `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
@@ -219,7 +220,7 @@ app/
- `POST /packets/maintenance`
### Read state
- `GET /read-state/unreads`
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
- `POST /read-state/mark-all-read`
### Settings

View File

@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
message_id: int
ack_count: int
paths: NotRequired[list[MessagePath]]
packet_id: NotRequired[int | None]
class ToastPayload(TypedDict):

View File

@@ -353,6 +353,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 45)
applied += 1
# Migration 46: Clean orphaned contact child rows left by old prefix promotion
if version < 46:
logger.info("Applying migration 46: clean orphaned contact child rows")
await _migrate_046_cleanup_orphaned_contact_child_rows(conn)
await set_version(conn, 46)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2773,3 +2780,91 @@ async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Con
await conn.execute("DROP TABLE contacts")
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
await conn.commit()
async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Connection) -> None:
"""Move uniquely resolvable orphan contact child rows onto full contacts, drop the rest."""
existing_tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await existing_tables_cursor.fetchall()}
if "contacts" not in existing_tables:
await conn.commit()
return
child_tables = [
table
for table in ("contact_name_history", "contact_advert_paths")
if table in existing_tables
]
if not child_tables:
await conn.commit()
return
orphan_keys: set[str] = set()
for table in child_tables:
cursor = await conn.execute(
f"""
SELECT DISTINCT child.public_key
FROM {table} child
LEFT JOIN contacts c ON c.public_key = child.public_key
WHERE c.public_key IS NULL
"""
)
orphan_keys.update(row[0] for row in await cursor.fetchall())
for orphan_key in sorted(orphan_keys, key=len, reverse=True):
match_cursor = await conn.execute(
"""
SELECT public_key
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
ORDER BY public_key
""",
(orphan_key.lower(),),
)
matches = [row[0] for row in await match_cursor.fetchall()]
resolved_key = matches[0] if len(matches) == 1 else None
if resolved_key is not None:
if "contact_name_history" in child_tables:
await conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(resolved_key, orphan_key),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(resolved_key, orphan_key),
)
if "contact_name_history" in child_tables:
await conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(orphan_key,),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(orphan_key,),
)
await conn.commit()

View File

@@ -413,6 +413,10 @@ class Message(BaseModel):
acked: int = 0
sender_name: str | None = None
channel_name: str | None = None
packet_id: int | None = Field(
default=None,
description="Representative raw packet row ID when archival raw bytes exist",
)
class MessagesAroundResponse(BaseModel):
@@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
decrypted_info: RawPacketDecryptedInfo | None = None
class RawPacketDetail(BaseModel):
"""Stored raw-packet detail returned by the packet API."""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
rssi: int | None = Field(
default=None, description="Received signal strength in dBm if available"
)
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
class SendMessageRequest(BaseModel):
text: str = Field(min_length=1)
@@ -814,4 +833,5 @@ class StatisticsResponse(BaseModel):
total_outgoing: int
contacts_heard: ContactActivityCounts
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats

View File

@@ -431,6 +431,43 @@ class ContactRepository:
Returns the placeholder public keys that were merged into the full key.
"""
async def migrate_child_rows(old_key: str, new_key: str) -> None:
await db.conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(new_key, old_key),
)
await db.conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(new_key, old_key),
)
await db.conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(old_key,),
)
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(old_key,),
)
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
@@ -467,6 +504,8 @@ class ContactRepository:
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
if full_exists:
await db.conn.execute(
"""

View File

@@ -331,6 +331,12 @@ class MessageRepository:
@staticmethod
def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model."""
packet_id = None
if hasattr(row, "keys"):
row_keys = row.keys()
if "packet_id" in row_keys:
packet_id = row["packet_id"]
return Message(
id=row["id"],
type=row["type"],
@@ -345,6 +351,14 @@ class MessageRepository:
outgoing=bool(row["outgoing"]),
acked=row["acked"],
sender_name=row["sender_name"],
packet_id=packet_id,
)
@staticmethod
def _message_select(message_alias: str = "messages") -> str:
return (
f"{message_alias}.*, "
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
)
@staticmethod
@@ -363,7 +377,7 @@ class MessageRepository:
) -> list[Message]:
search_query = MessageRepository._parse_search_query(q) if q else None
query = (
"SELECT messages.* FROM messages "
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' "
@@ -470,7 +484,8 @@ class MessageRepository:
# 1. Get the target message (must satisfy filters if provided)
target_cursor = await db.conn.execute(
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
f"SELECT {MessageRepository._message_select('messages')} "
f"FROM messages WHERE id = ? AND {where_sql}",
(message_id, *base_params),
)
target_row = await target_cursor.fetchone()
@@ -481,7 +496,7 @@ class MessageRepository:
# 2. Get context_size+1 messages before target (DESC)
before_query = f"""
SELECT * FROM messages WHERE {where_sql}
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
AND (received_at < ? OR (received_at = ? AND id < ?))
ORDER BY received_at DESC, id DESC LIMIT ?
"""
@@ -500,7 +515,7 @@ class MessageRepository:
# 3. Get context_size+1 messages after target (ASC)
after_query = f"""
SELECT * FROM messages WHERE {where_sql}
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
AND (received_at > ? OR (received_at = ? AND id > ?))
ORDER BY received_at ASC, id ASC LIMIT ?
"""
@@ -545,7 +560,7 @@ class MessageRepository:
async def get_by_id(message_id: int) -> "Message | None":
"""Look up a message by its ID."""
cursor = await db.conn.execute(
"SELECT * FROM messages WHERE id = ?",
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
(message_id,),
)
row = await cursor.fetchone()
@@ -554,6 +569,12 @@ class MessageRepository:
return MessageRepository._row_to_message(row)
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
@staticmethod
async def get_by_content(
msg_type: str,
@@ -564,7 +585,9 @@ class MessageRepository:
) -> "Message | None":
"""Look up a message by its unique content fields."""
query = """
SELECT * FROM messages
SELECT messages.*,
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
FROM messages
WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
"""

View File

@@ -121,6 +121,18 @@ class RawPacketRepository:
return None
return row["message_id"]
@staticmethod
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
"""Return a raw packet row as (id, data, timestamp, message_id)."""
cursor = await db.conn.execute(
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
(packet_id,),
)
row = await cursor.fetchone()
if not row:
return None
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
@staticmethod
async def prune_old_undecrypted(max_age_days: int) -> int:
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""

View File

@@ -270,6 +270,30 @@ class StatisticsRepository:
"last_week": row["last_week"] or 0,
}
@staticmethod
async def _known_channels_active() -> dict[str, int]:
"""Count distinct known channel keys with channel traffic in each time window."""
now = int(time.time())
cursor = await db.conn.execute(
"""
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'
""",
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
)
row = await cursor.fetchone()
assert row is not None
return {
"last_hour": row["last_hour"] or 0,
"last_24_hours": row["last_24_hours"] or 0,
"last_week": row["last_week"] or 0,
}
@staticmethod
async def _path_hash_width_24h() -> dict[str, int | float]:
"""Count parsed raw packets from the last 24h by hop hash width."""
@@ -396,6 +420,7 @@ class StatisticsRepository:
# Activity windows
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
return {
@@ -411,5 +436,6 @@ class StatisticsRepository:
"total_outgoing": total_outgoing,
"contacts_heard": contacts_heard,
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h,
}

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository
from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -87,11 +87,18 @@ class DebugRadioProbe(BaseModel):
channels: DebugChannelAudit | None = None
class DebugDatabaseInfo(BaseModel):
total_dms: int
total_channel_messages: int
total_outgoing: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
application: DebugApplicationInfo
health: HealthResponse
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
@@ -258,6 +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()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
@@ -282,6 +290,11 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
database=DebugDatabaseInfo(
total_dms=statistics["total_dms"],
total_channel_messages=statistics["total_channel_messages"],
total_outgoing=statistics["total_outgoing"],
),
radio_probe=radio_probe,
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
)

View File

@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
from app.database import db
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import RawPacketDecryptedInfo, RawPacketDetail
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
from app.repository import ChannelRepository, RawPacketRepository
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_success
logger = logging.getLogger(__name__)
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
return {"count": count}
@router.get("/{packet_id}", response_model=RawPacketDetail)
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
"""Fetch one stored raw packet by row ID for on-demand inspection."""
packet_row = await RawPacketRepository.get_by_id(packet_id)
if packet_row is None:
raise HTTPException(status_code=404, detail="Raw packet not found")
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
packet_info = parse_packet(packet_data)
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
decrypted_info: RawPacketDecryptedInfo | None = None
if message_id is not None:
message = await MessageRepository.get_by_id(message_id)
if message is not None:
if message.type == "CHAN":
channel = await ChannelRepository.get_by_key(message.conversation_key)
decrypted_info = RawPacketDecryptedInfo(
channel_name=channel.name if channel else None,
sender=message.sender_name,
channel_key=message.conversation_key,
contact_key=message.sender_key,
)
else:
decrypted_info = RawPacketDecryptedInfo(
sender=message.sender_name,
contact_key=message.conversation_key,
)
return RawPacketDetail(
id=stored_packet_id,
timestamp=packet_timestamp,
data=packet_data.hex(),
payload_type=payload_type_name,
decrypted=message_id is not None,
decrypted_info=decrypted_info,
)
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response

View File

@@ -238,6 +238,7 @@ async def _store_direct_message(
sender_key=sender_key,
outgoing=outgoing,
sender_name=sender_name,
packet_id=packet_id,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)

View File

@@ -13,7 +13,8 @@ from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
from app.services import dm_ack_tracker
from app.services.messages import (
build_message_model,
broadcast_message,
build_stored_outgoing_channel_message,
create_outgoing_channel_message,
create_outgoing_direct_message,
increment_ack_and_broadcast,
@@ -586,6 +587,23 @@ async def send_channel_message_to_channel(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -611,23 +629,11 @@ async def send_channel_message_to_channel(
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
)
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
except Exception:
if outgoing_message is not None:
await message_repository.delete_by_id(outgoing_message.id)
outgoing_message = None
raise
finally:
if sender_timestamp is not None:
await release_outgoing_sender_timestamp(
@@ -640,22 +646,19 @@ async def send_channel_message_to_channel(
if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
message_id = outgoing_message.id
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
outgoing_message = await build_stored_outgoing_channel_message(
message_id=outgoing_message.id,
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
return outgoing_message
async def resend_channel_message_record(
@@ -705,6 +708,23 @@ async def resend_channel_message_record(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -729,26 +749,11 @@ async def resend_channel_message_record(
status_code=500,
detail=f"Failed to resend message: {result.payload}",
)
if new_timestamp:
if sent_at is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
except Exception:
if new_message is not None:
await message_repository.delete_by_id(new_message.id)
new_message = None
raise
finally:
if new_timestamp and sent_at is not None:
await release_outgoing_sender_timestamp(
@@ -762,6 +767,19 @@ async def resend_channel_message_record(
if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await build_stored_outgoing_channel_message(
message_id=new_message.id,
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
logger.info(
"Resent channel message %d as new message %d to %s",
message.id,

View File

@@ -62,6 +62,7 @@ def build_message_model(
acked: int = 0,
sender_name: str | None = None,
channel_name: str | None = None,
packet_id: int | None = None,
) -> Message:
"""Build a Message model with the canonical backend payload shape."""
return Message(
@@ -79,6 +80,7 @@ def build_message_model(
acked=acked,
sender_name=sender_name,
channel_name=channel_name,
packet_id=packet_id,
)
@@ -96,11 +98,42 @@ def broadcast_message(
broadcast_fn("message", payload, realtime=realtime)
async def build_stored_outgoing_channel_message(
*,
message_id: int,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
sender_name: str | None,
sender_key: str | None,
channel_name: str | None,
message_repository=MessageRepository,
) -> Message:
"""Build the current payload for a stored outgoing channel message."""
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
)
def broadcast_message_acked(
*,
message_id: int,
ack_count: int,
paths: list[MessagePath] | None,
packet_id: int | None,
broadcast_fn: BroadcastFn,
) -> None:
"""Broadcast a message_acked payload."""
@@ -110,6 +143,7 @@ def broadcast_message_acked(
"message_id": message_id,
"ack_count": ack_count,
"paths": [path.model_dump() for path in paths] if paths else [],
"packet_id": packet_id,
},
)
@@ -152,11 +186,16 @@ async def reconcile_duplicate_message(
else:
ack_count = existing_msg.acked
representative_packet_id = (
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
)
if existing_msg.outgoing or path is not None:
broadcast_message_acked(
message_id=existing_msg.id,
ack_count=ack_count,
paths=paths,
packet_id=representative_packet_id,
broadcast_fn=broadcast_fn,
)
@@ -277,6 +316,7 @@ async def create_message_from_decrypted(
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
packet_id=packet_id,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
@@ -428,6 +468,7 @@ async def create_outgoing_channel_message(
sender_key: str | None,
channel_name: str | None,
broadcast_fn: BroadcastFn,
broadcast: bool = True,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast an outgoing channel message."""
@@ -444,18 +485,17 @@ async def create_outgoing_channel_message(
if msg_id is None:
return None
message = build_message_model(
message = await build_stored_outgoing_channel_message(
message_id=msg_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
acked=0,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
message_repository=message_repository,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
if broadcast:
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message

View File

@@ -198,9 +198,9 @@ High-level state is delegated to hooks:
- `useContactsAndChannels`: contact/channel lists, creation, deletion
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
@@ -331,6 +331,8 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
## Contact Info Pane
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.4.1",
"version": "3.6.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RawPacket,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
@@ -247,6 +248,7 @@ export const api = {
),
// Packets
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';

View File

@@ -9,6 +9,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
@@ -118,8 +119,15 @@ export function ChatHeader({
};
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
<header
className={cn(
'conversation-header grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
conversation.type === 'contact' && activeContact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<button
type="button"
@@ -137,16 +145,31 @@ export function ChatHeader({
/>
</button>
)}
<span className="grid min-w-0 flex-1 grid-cols-1 gap-y-0.5 min-[1200px]:grid-cols-[minmax(0,1fr)_auto] min-[1200px]:items-baseline min-[1200px]:gap-x-2">
<span className="flex min-w-0 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
@@ -155,83 +178,72 @@ export function ChatHeader({
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel'
? conversation.id.toLowerCase()
: conversation.id}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
</span>
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
className="mt-0.5 flex basis-full items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
Show Key
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
) : (
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && (
<span className="min-w-0 text-[11px] text-muted-foreground min-[1200px]:justify-self-end">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && activeContact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</div>
)}
<div className="flex items-center justify-end gap-0.5">
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

View File

@@ -261,6 +261,7 @@ export function ConversationPane({
key={activeConversation.id}
messages={messages}
contacts={contacts}
channels={channels}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}

View File

@@ -8,19 +8,23 @@ import {
useState,
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface MessageListProps {
messages: Message[];
contacts: Contact[];
channels?: Channel[];
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const RESEND_WINDOW_SECONDS = 30;
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
const ANALYZE_PACKET_NOTICE =
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
function hasUnexpectedControlChars(text: string): boolean {
for (const char of text) {
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
export function MessageList({
messages,
contacts,
channels = [],
loading,
loadingOlder = false,
hasOlderMessages = false,
@@ -199,10 +206,18 @@ export function MessageList({
paths: MessagePath[];
senderInfo: SenderInfo;
messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean;
} | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
const [packetInspectorSource, setPacketInspectorSource] = useState<
| { kind: 'packet'; packet: RawPacket }
| { kind: 'loading'; message: string }
| { kind: 'unavailable'; message: string }
| null
>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
@@ -221,6 +236,43 @@ export function MessageList({
// Track conversation key to detect when entire message set changes
const prevConvKeyRef = useRef<string | null>(null);
const handleAnalyzePacket = useCallback(async (message: Message) => {
if (message.packet_id == null) {
setPacketInspectorSource({
kind: 'unavailable',
message:
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
});
return;
}
const cached = packetCacheRef.current.get(message.packet_id);
if (cached) {
setPacketInspectorSource({ kind: 'packet', packet: cached });
return;
}
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
try {
const packet = await api.getPacket(message.packet_id);
packetCacheRef.current.set(message.packet_id, packet);
setPacketInspectorSource({ kind: 'packet', packet });
} catch (error) {
const description = error instanceof Error ? error.message : 'Unknown error';
const isMissing = error instanceof Error && /not found/i.test(error.message);
if (!isMissing) {
toast.error('Failed to load raw packet', { description });
}
setPacketInspectorSource({
kind: 'unavailable',
message: isMissing
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
: `Could not load the archival raw packet for this message: ${description}`,
});
}
}, []);
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
@@ -833,6 +885,8 @@ export function MessageList({
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
})
}
/>
@@ -859,6 +913,8 @@ export function MessageList({
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
})
}
/>
@@ -879,6 +935,7 @@ export function MessageList({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
@@ -900,6 +957,7 @@ export function MessageList({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: true,
});
}}
@@ -997,9 +1055,31 @@ export function MessageList({
contacts={contacts}
config={config ?? null}
messageId={selectedPath.messageId}
packetId={selectedPath.packetId}
isOutgoingChan={selectedPath.isOutgoingChan}
isResendable={isSelectedMessageResendable}
onResend={onResendChannelMessage}
onAnalyzePacket={
selectedPath.packetId != null
? () => {
const message = messages.find((entry) => entry.id === selectedPath.messageId);
if (message) {
void handleAnalyzePacket(message);
}
}
: undefined
}
/>
)}
{packetInspectorSource && (
<RawPacketInspectorDialog
open={packetInspectorSource !== null}
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
channels={channels}
source={packetInspectorSource}
title="Analyze Packet"
description="On-demand raw packet analysis for a message-backed archival packet."
notice={ANALYZE_PACKET_NOTICE}
/>
)}
</div>

View File

@@ -29,9 +29,11 @@ interface PathModalProps {
contacts: Contact[];
config: RadioConfig | null;
messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean;
isResendable?: boolean;
onResend?: (messageId: number, newTimestamp?: boolean) => void;
onAnalyzePacket?: () => void;
}
export function PathModal({
@@ -42,14 +44,17 @@ export function PathModal({
contacts,
config,
messageId,
packetId,
isOutgoingChan,
isResendable,
onResend,
onAnalyzePacket,
}: PathModalProps) {
const { distanceUnit } = useDistanceUnit();
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
// Resolve all paths
const resolvedPaths = hasPaths
@@ -90,6 +95,12 @@ export function PathModal({
{hasPaths && (
<div className="flex-1 overflow-y-auto py-2 space-y-4">
{showAnalyzePacket ? (
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
Analyze Packet
</Button>
) : null}
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
@@ -8,6 +8,8 @@ import {
inspectRawPacketWithOptions,
type PacketByteField,
} from '../utils/rawPacketInspector';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
interface RawPacketDetailModalProps {
@@ -16,6 +18,38 @@ interface RawPacketDetailModalProps {
onClose: () => void;
}
type RawPacketInspectorDialogSource =
| {
kind: 'packet';
packet: RawPacket;
}
| {
kind: 'paste';
}
| {
kind: 'loading';
message: string;
}
| {
kind: 'unavailable';
message: string;
};
interface RawPacketInspectorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channels: Channel[];
source: RawPacketInspectorDialogSource;
title: string;
description: string;
notice?: ReactNode;
}
interface RawPacketInspectionPanelProps {
packet: RawPacket;
channels: Channel[];
}
interface FieldPaletteEntry {
box: string;
boxActive: string;
@@ -358,6 +392,36 @@ function renderFieldValue(field: PacketByteField) {
);
}
function normalizePacketHex(input: string): string {
return input.replace(/\s+/g, '').toUpperCase();
}
function validatePacketHex(input: string): string | null {
if (!input) {
return 'Paste a packet hex string to analyze.';
}
if (!/^[0-9A-F]+$/.test(input)) {
return 'Packet hex may only contain 0-9 and A-F characters.';
}
if (input.length % 2 !== 0) {
return 'Packet hex must contain an even number of characters.';
}
return null;
}
function buildPastedRawPacket(packetHex: string): RawPacket {
return {
id: -1,
timestamp: Math.floor(Date.now() / 1000),
data: packetHex,
payload_type: 'Unknown',
snr: null,
rssi: null,
decrypted: false,
decrypted_info: null,
};
}
function FieldBox({
field,
palette,
@@ -500,145 +564,256 @@ function FieldSection({
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels),
[channels]
);
const inspection = useMemo(
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
() => inspectRawPacketWithOptions(packet, decoderOptions),
[decoderOptions, packet]
);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const packetDisplayFields = useMemo(
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
[inspection]
);
const fullPacketFields = useMemo(
() => (inspection ? buildDisplayFields(inspection) : []),
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
[inspection]
);
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
const packetContext = useMemo(
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
() => getPacketContext(packet, inspection, groupTextCandidates),
[groupTextCandidates, inspection, packet]
);
const packetIsDecrypted = useMemo(
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
() => packetShowsDecryptedState(packet, inspection),
[inspection, packet]
);
if (!packet || !inspection) {
return null;
return (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(packet.data);
toast.success('Packet hex copied!');
}}
>
Copy
</Button>
</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
);
}
export function RawPacketInspectorDialog({
open,
onOpenChange,
channels,
source,
title,
description,
notice,
}: RawPacketInspectorDialogProps) {
const [packetInput, setPacketInput] = useState('');
useEffect(() => {
if (!open || source.kind !== 'paste') {
setPacketInput('');
}
}, [open, source.kind]);
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
const packetInputError = useMemo(
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
[normalizedPacketInput]
);
const analyzedPacket = useMemo(
() =>
normalizedPacketInput.length > 0 && packetInputError === null
? buildPastedRawPacket(normalizedPacketInput)
: null,
[normalizedPacketInput, packetInputError]
);
let body: ReactNode;
if (source.kind === 'packet') {
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
} else if (source.kind === 'paste') {
body = (
<>
<div className="border-b border-border px-4 py-3 pr-14">
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
Packet Hex
</label>
<textarea
id="raw-packet-input"
value={packetInput}
onChange={(event) => setPacketInput(event.target.value)}
placeholder="Paste raw packet hex here..."
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
spellCheck={false}
/>
{packetInputError ? (
<div className="text-sm text-destructive">{packetInputError}</div>
) : null}
</div>
</div>
{analyzedPacket ? (
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
) : (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
Paste a packet above to inspect it.
</div>
)}
</>
);
} else if (source.kind === 'loading') {
body = (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
{source.message}
</div>
);
} else {
body = (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
{source.message}
</div>
</div>
);
}
return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>Packet Details</DialogTitle>
<DialogDescription className="sr-only">
Detailed byte and field breakdown for the selected raw packet.
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
{notice ? (
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
{notice}
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
) : null}
{body}
</DialogContent>
</Dialog>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
if (!packet) {
return null;
}
return (
<RawPacketInspectorDialog
open={packet !== null}
onOpenChange={(isOpen) => !isOpen && onClose()}
channels={channels}
source={{ kind: 'packet', packet }}
title="Packet Details"
description="Detailed byte and field breakdown for the selected raw packet."
/>
);
}

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList';
import { RawPacketDetailModal } from './RawPacketDetailModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types';
import {
RAW_PACKET_STATS_WINDOWS,
@@ -77,12 +78,16 @@ function formatRssi(value: number | null): string {
return value === null ? '-' : `${Math.round(value)} dBm`;
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
function normalizeResolvableSourceKey(sourceKey: string): string {
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
}
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
if (!sourceKey || sourceKey.startsWith('name:')) {
return null;
}
const normalizedSourceKey = sourceKey.toLowerCase();
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
const matches = contacts.filter((contact) =>
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
);
@@ -90,7 +95,14 @@ function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): str
return null;
}
const contact = matches[0];
return matches[0];
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
const contact = resolveContact(sourceKey, contacts);
if (!contact) {
return null;
}
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
}
@@ -101,11 +113,46 @@ function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat
};
}
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
const merged = new Map<string, NeighborStat>();
for (const item of items) {
const contact = resolveContact(item.key, contacts);
const canonicalKey = contact?.public_key ?? item.key;
const resolvedLabel =
contact != null
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
: item.label;
const existing = merged.get(canonicalKey);
if (!existing) {
merged.set(canonicalKey, {
...item,
key: canonicalKey,
label: resolvedLabel,
});
continue;
}
existing.count += item.count;
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
existing.bestRssi =
existing.bestRssi === null
? item.bestRssi
: item.bestRssi === null
? existing.bestRssi
: Math.max(existing.bestRssi, item.bestRssi);
existing.label = resolvedLabel;
}
return Array.from(merged.values());
}
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
if (item.key.startsWith('name:')) {
return true;
}
return resolveContactLabel(item.key, contacts) !== null;
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
@@ -219,14 +266,29 @@ function NeighborList({
mode: 'heard' | 'signal' | 'recent';
contacts: Contact[];
}) {
const mergedItems = mergeResolvedNeighbors(items, contacts);
const sortedItems = [...mergedItems].sort((a, b) => {
if (mode === 'heard') {
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
}
if (mode === 'signal') {
return (
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
b.count - a.count ||
a.label.localeCompare(b.label)
);
}
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
});
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
{sortedItems.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
{sortedItems.map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
@@ -324,6 +386,7 @@ export function RawPacketFeedView({
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
useEffect(() => {
const interval = window.setInterval(() => {
@@ -357,7 +420,6 @@ export function RawPacketFeedView({
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.newestNeighbors]
);
return (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
@@ -367,15 +429,26 @@ export function RawPacketFeedView({
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
@@ -538,10 +611,26 @@ export function RawPacketFeedView({
</aside>
</div>
<RawPacketDetailModal
packet={selectedPacket}
<RawPacketInspectorDialog
open={selectedPacket !== null}
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
channels={channels}
onClose={() => setSelectedPacket(null)}
source={
selectedPacket
? { kind: 'packet', packet: selectedPacket }
: { kind: 'loading', message: 'Loading packet...' }
}
title="Packet Details"
description="Detailed byte and field breakdown for the selected raw packet."
/>
<RawPacketInspectorDialog
open={analyzeModalOpen}
onOpenChange={setAnalyzeModalOpen}
channels={channels}
source={{ kind: 'paste' }}
title="Analyze Packet"
description="Paste and inspect a raw packet hex string."
/>
</>
);

View File

@@ -12,6 +12,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
@@ -101,8 +102,15 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<header
className={cn(
'grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
contact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
@@ -122,14 +130,14 @@ export function RepeaterDashboard({
{conversation.id}
</span>
</span>
{contact && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</span>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{contact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</div>
)}
<div className="flex items-center gap-0.5">
{loggedIn && (
<Button
variant="outline"

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import type {
Contact,
PaneState,
@@ -59,7 +60,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
useRememberedServerPassword('room', contact.public_key);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginMessage, setLoginMessage] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
@@ -74,7 +74,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
useEffect(() => {
setLoginLoading(false);
setLoginError(null);
setLoginMessage(null);
setAuthenticated(false);
setAdvancedOpen(false);
setPaneData({
@@ -135,20 +134,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
setLoginLoading(true);
setLoginError(null);
setLoginMessage(null);
try {
const result = await api.roomLogin(contact.public_key, password);
setAuthenticated(true);
setLoginMessage(
result.message ??
(result.authenticated
? 'Login confirmed. You can now send room messages and open admin tools.'
: 'Login request sent, but authentication was not confirmed.')
);
if (result.authenticated) {
toast.success('Room login confirmed');
} else {
toast(result.message ?? 'Room login was not confirmed');
toast.warning('Room login not confirmed', {
description: result.message ?? 'Room login was not confirmed',
});
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
@@ -251,62 +245,69 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Room Server Controls</div>
<p className="text-xs text-muted-foreground">
Room access is active. Use the chat history and message box below to participate, and
open admin tools when needed.
</p>
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Refresh ACL Login
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
{advancedOpen && (
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Room Server Tools</SheetTitle>
<SheetDescription>
Room server telemetry, ACL tools, sensor data, and CLI console
</SheetDescription>
</SheetHeader>
<div className="border-b border-border px-4 py-3 pr-14">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
className="self-start sm:self-auto"
>
Refresh ACL Login
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
</div>
</SheetContent>
</Sheet>
</section>
);
}

View File

@@ -945,21 +945,6 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Repeaters */}
{nonFavoriteRepeaters.length > 0 && (
<>
@@ -975,6 +960,21 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Empty state */}
{nonFavoriteContacts.length === 0 &&
nonFavoriteRooms.length === 0 &&

View File

@@ -173,8 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality, nor impact your
ability to do historical decryption.
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>

View File

@@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
</tr>
<tr>
<td className="py-1">Known-channels active</td>
<td className="text-right py-1">{stats.known_channels_active.last_hour}</td>
<td className="text-right py-1">{stats.known_channels_active.last_24_hours}</td>
<td className="text-right py-1">{stats.known_channels_active.last_week}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -21,6 +21,24 @@ interface InternalCachedConversationEntry extends CachedConversationEntry {
export class ConversationMessageCache {
private readonly cache = new Map<string, InternalCachedConversationEntry>();
private normalizeEntry(entry: CachedConversationEntry): InternalCachedConversationEntry {
let messages = entry.messages;
let hasOlderMessages = entry.hasOlderMessages;
if (messages.length > MAX_MESSAGES_PER_ENTRY) {
messages = [...messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
hasOlderMessages = true;
}
return {
messages,
hasOlderMessages,
contentKeys: new Set(messages.map((message) => getMessageContentKey(message))),
};
}
get(id: string): CachedConversationEntry | undefined {
const entry = this.cache.get(id);
if (!entry) return undefined;
@@ -33,17 +51,7 @@ export class ConversationMessageCache {
}
set(id: string, entry: CachedConversationEntry): void {
const contentKeys = new Set(entry.messages.map((message) => getMessageContentKey(message)));
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
const trimmed = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
}
const internalEntry: InternalCachedConversationEntry = {
...entry,
contentKeys,
};
const internalEntry = this.normalizeEntry(entry);
this.cache.delete(id);
this.cache.set(id, internalEntry);
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
@@ -69,19 +77,21 @@ export class ConversationMessageCache {
}
if (entry.contentKeys.has(contentKey)) return false;
if (entry.messages.some((message) => message.id === msg.id)) return false;
entry.contentKeys.add(contentKey);
entry.messages = [...entry.messages, msg];
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
entry.messages = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
const nextEntry = this.normalizeEntry({
messages: [...entry.messages, msg],
hasOlderMessages: entry.hasOlderMessages,
});
this.cache.delete(id);
this.cache.set(id, entry);
this.cache.set(id, nextEntry);
return true;
}
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
updateAck(
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
): void {
for (const entry of this.cache.values()) {
const index = entry.messages.findIndex((message) => message.id === messageId);
if (index < 0) continue;
@@ -91,6 +101,7 @@ export class ConversationMessageCache {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
...(packetId !== undefined && { packet_id: packetId }),
};
entry.messages = updated;
return;
@@ -123,11 +134,13 @@ export class ConversationMessageCache {
}
this.cache.delete(oldId);
this.cache.set(newId, {
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
});
this.cache.set(
newId,
this.normalizeEntry({
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
})
);
}
clear(): void {
@@ -139,12 +152,16 @@ export function reconcileConversationMessages(
current: Message[],
fetched: Message[]
): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
const currentById = new Map<
number,
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
>();
for (const message of current) {
currentById.set(message.id, {
acked: message.acked,
pathsLen: message.paths?.length ?? 0,
text: message.text,
packetId: message.packet_id,
});
}
@@ -155,7 +172,8 @@ export function reconcileConversationMessages(
!currentMessage ||
currentMessage.acked !== message.acked ||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
currentMessage.text !== message.text
currentMessage.text !== message.text ||
currentMessage.packetId !== message.packet_id
) {
needsUpdate = true;
break;
@@ -173,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
interface PendingAckUpdate {
ackCount: number;
paths?: MessagePath[];
packetId?: number | null;
}
export function mergePendingAck(
existing: PendingAckUpdate | undefined,
ackCount: number,
paths?: MessagePath[]
paths?: MessagePath[],
packetId?: number | null
): PendingAckUpdate {
if (!existing) {
return {
ackCount,
...(paths !== undefined && { paths }),
...(packetId !== undefined && { packetId }),
};
}
@@ -192,6 +213,9 @@ export function mergePendingAck(
ackCount,
...(paths !== undefined && { paths }),
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
...(packetId !== undefined && { packetId }),
...(packetId === undefined &&
existing.packetId !== undefined && { packetId: existing.packetId }),
};
}
@@ -199,16 +223,31 @@ export function mergePendingAck(
return existing;
}
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
if (paths === undefined) {
return existing;
if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
}
const existingPathCount = existing.paths?.length ?? -1;
if (paths.length >= existingPathCount) {
return { ackCount, paths };
return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
}
return existing;
if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
}
interface UseConversationMessagesResult {
@@ -223,7 +262,12 @@ interface UseConversationMessagesResult {
jumpToBottom: () => void;
reloadCurrentConversation: () => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
@@ -284,9 +328,9 @@ export function useConversationMessages(
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const existing = pendingAcksRef.current.get(messageId);
const merged = mergePendingAck(existing, ackCount, paths);
const merged = mergePendingAck(existing, ackCount, paths, packetId);
// Update insertion order so most recent updates remain in the buffer longest.
pendingAcksRef.current.delete(messageId);
@@ -312,6 +356,7 @@ export function useConversationMessages(
...msg,
acked: Math.max(msg.acked, pending.ackCount),
...(pending.paths !== undefined && { paths: pending.paths }),
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
};
}, []);
const [messages, setMessages] = useState<Message[]>([]);
@@ -775,10 +820,10 @@ export function useConversationMessages(
// Update a message's ack count and paths
const updateMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
if (!hasMessageLoaded) {
setPendingAck(messageId, ackCount, paths);
setPendingAck(messageId, ackCount, paths, packetId);
return;
}
@@ -800,10 +845,11 @@ export function useConversationMessages(
...current,
acked: nextAck,
...(paths !== undefined && { paths: nextPaths }),
...(packetId !== undefined && { packet_id: packetId }),
};
return updated;
}
setPendingAck(messageId, ackCount, paths);
setPendingAck(messageId, ackCount, paths, packetId);
return prev;
});
},
@@ -811,9 +857,9 @@ export function useConversationMessages(
);
const receiveMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
conversationMessageCache.updateAck(messageId, ackCount, paths);
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
updateMessageAck(messageId, ackCount, paths, packetId);
conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
},
[updateMessageAck]
);

View File

@@ -48,7 +48,12 @@ interface UseRealtimeAppStateArgs {
setActiveConversation: (conv: Conversation | null) => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void;
maxRawPackets?: number;
@@ -246,8 +251,13 @@ export function useRealtimeAppState({
recordRawPacketObservation?.(packet);
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
receiveMessageAck(messageId, ackCount, paths);
onMessageAcked: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => {
receiveMessageAck(messageId, ackCount, paths, packetId);
},
}),
[

View File

@@ -167,17 +167,15 @@ describe('Integration: Duplicate Message Handling', () => {
});
});
describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regression)', () => {
it('does not increment unread when a mesh echo arrives after many unique messages', () => {
describe('Integration: Trimmed cache entries can reappear (hitlist #7 regression)', () => {
it('increments unread when an evicted inactive-conversation message arrives again', () => {
const state = createMockState();
const convKey = 'channel_busy';
// Deliver 1001 unique messages — exceeding the old global
// seenMessageContentRef prune threshold (1000→500). Under the old
// dual-set design the global set would drop msg-0's key during pruning,
// so a later mesh echo of msg-0 would pass the global check and
// phantom-increment unread. With the fix, messageCache's per-conversation
// Cached messages remain the source of truth for inactive-conversation dedup.
// Deliver enough unique messages to evict msg-0 from the inactive
// conversation cache. Once it falls out of that window, a later arrival
// with the same content should be allowed back in instead of being
// suppressed forever by a stale content key.
const MESSAGE_COUNT = 1001;
for (let i = 0; i < MESSAGE_COUNT; i++) {
const msg: Message = {
@@ -219,9 +217,8 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
};
const result = handleMessageEvent(state, echo, 'other_active_conv');
// Must NOT increment unread — the echo is a duplicate
expect(result.unreadIncremented).toBe(false);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
expect(result.unreadIncremented).toBe(true);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT + 1);
});
});

View File

@@ -214,6 +214,76 @@ describe('messageCache', () => {
expect(entry!.messages.some((m) => m.id === 0)).toBe(false);
});
it('allows a trimmed-out message to be re-added after set() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
const trimmedOut = createMessage({
id: 10_000,
text: 'message-0',
received_at: 1800000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', trimmedOut)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_000)).toBe(true);
});
it('allows a trimmed-out message to be re-added after addMessage() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY - 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY,
text: 'newest-a',
received_at: 1800000000,
sender_timestamp: 1800000000,
})
)
).toBe(true);
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY + 1,
text: 'newest-b',
received_at: 1800000001,
sender_timestamp: 1800000001,
})
)
).toBe(true);
const readdedTrimmedMessage = createMessage({
id: 10_001,
text: 'message-0',
received_at: 1900000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', readdedTrimmedMessage)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_001)).toBe(true);
});
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage('new_conv', msg);

View File

@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
import type { Channel, RawPacket } from '../types';
vi.mock('../components/ui/sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
}),
}));
const { toast } = await import('../components/ui/sonner');
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
};
const BOT_CHANNEL: Channel = {
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
name: '#bot',
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
};
describe('RawPacketDetailModal', () => {
it('copies the full packet hex to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText },
});
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
});
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);

View File

@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
});
it('analyzes a pasted raw packet without adding it to the live feed', () => {
renderView({ channels: [TEST_CHANNEL] });
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Packet Hex'), {
target: { value: GROUP_TEXT_PACKET_HEX },
});
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
expect(screen.getByText('Packet fields')).toBeInTheDocument();
expect(screen.getByText('Payload fields')).toBeInTheDocument();
});
it('shows stats by default on desktop', () => {
vi.stubGlobal(
'matchMedia',
@@ -297,6 +313,54 @@ describe('RawPacketFeedView', () => {
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
});
it('collapses uniquely resolved hash buckets into the same visible contact row', () => {
const alphaContact = createContact({
public_key: 'aa11bb22cc33' + '0'.repeat(52),
name: 'Alpha',
});
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 2,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -70,
snr: 6,
sourceKey: 'hash1:AA',
sourceLabel: 'AA',
pathTokenCount: 0,
pathSignature: null,
},
{
observationKey: 'obs-2',
timestamp: 1_700_000_030,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -67,
snr: 7,
sourceKey: alphaContact.public_key.toUpperCase(),
sourceLabel: alphaContact.public_key.slice(0, 12).toUpperCase(),
pathTokenCount: 0,
pathSignature: null,
},
],
}),
contacts: [alphaContact],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
renderView({
packets: [

View File

@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
buildRawPacketStatsSnapshot,
summarizeRawPacketForStats,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
import type { RawPacket } from '../types';
const TEXT_MESSAGE_PACKET = '09046F17C47ED00A13E16AB5B94B1CC2D1A5059C6E5A6253C60D';
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
@@ -75,6 +79,49 @@ function createSession(
}
describe('buildRawPacketStatsSnapshot', () => {
it('prefers decrypted contact identity over one-byte sourceHash for stats bucketing', () => {
const packet: RawPacket = {
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: TEXT_MESSAGE_PACKET,
payload_type: 'TextMessage',
snr: 4,
rssi: -72,
decrypted: true,
decrypted_info: {
channel_name: null,
sender: 'Alpha',
channel_key: null,
contact_key: '0a'.repeat(32),
},
};
const summary = summarizeRawPacketForStats(packet);
expect(summary.sourceKey).toBe('0A'.repeat(32));
expect(summary.sourceLabel).toBe('Alpha');
});
it('tags unresolved one-byte source hashes so they do not collide with full contact keys', () => {
const packet: RawPacket = {
id: 2,
observation_id: 11,
timestamp: 1_700_000_000,
data: TEXT_MESSAGE_PACKET,
payload_type: 'TextMessage',
snr: 4,
rssi: -72,
decrypted: false,
decrypted_info: null,
};
const summary = summarizeRawPacketForStats(packet);
expect(summary.sourceKey).toBe('hash1:0A');
expect(summary.sourceLabel).toBe('0A');
});
it('computes counts, rankings, and rolling-window coverage from session observations', () => {
const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000);

View File

@@ -24,6 +24,8 @@ vi.mock('../components/ui/sonner', () => ({
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const { toast } = await import('../components/ui/sonner');
const mockToast = toast as unknown as Record<string, Mock>;
const roomContact: Contact = {
public_key: 'aa'.repeat(32),
@@ -63,9 +65,13 @@ describe('RoomServerPanel', () => {
fireEvent.click(screen.getByText('Login with ACL / Guest'));
await waitFor(() => {
expect(screen.getByText('Room Server Controls')).toBeInTheDocument();
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.getByText('Show Tools')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
description:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
});
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
});
});

View File

@@ -557,6 +557,10 @@ describe('SettingsModal', () => {
renderModal();
openDatabaseSection();
expect(
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => {
@@ -580,6 +584,7 @@ describe('SettingsModal', () => {
total_outgoing: 30,
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
path_hash_width_24h: {
total_packets: 120,
single_byte: 60,
@@ -626,6 +631,7 @@ describe('SettingsModal', () => {
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();
@@ -646,6 +652,7 @@ describe('SettingsModal', () => {
total_outgoing: 30,
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
path_hash_width_24h: {
total_packets: 120,
single_byte: 60,

View File

@@ -136,7 +136,7 @@ describe('useWebSocket dispatch', () => {
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
});
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths, packetId)', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
@@ -144,7 +144,7 @@ describe('useWebSocket dispatch', () => {
fireMessage({ type, data });
expect(onMessageAcked).toHaveBeenCalledOnce();
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined);
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined, undefined);
});
it('routes message_acked with paths', () => {
@@ -154,7 +154,16 @@ describe('useWebSocket dispatch', () => {
const paths = [{ path: 'aabb', received_at: 1700000000 }];
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths);
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths, undefined);
});
it('routes message_acked with packet_id', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, packet_id: 99 } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, undefined, 99);
});
it('routes error event to onError', () => {

View File

@@ -267,6 +267,7 @@ export interface Message {
acked: number;
sender_name: string | null;
channel_name?: string | null;
packet_id?: number | null;
}
export interface MessagesAroundResponse {
@@ -513,6 +514,7 @@ export interface StatisticsResponse {
total_outgoing: number;
contacts_heard: ContactActivityCounts;
repeaters_heard: ContactActivityCounts;
known_channels_active: ContactActivityCounts;
path_hash_width_24h: {
total_packets: number;
single_byte: number;

View File

@@ -21,7 +21,12 @@ export interface UseWebSocketOptions {
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
onMessageAcked?: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
onError?: (error: ErrorEvent) => void;
onSuccess?: (success: SuccessEvent) => void;
onReconnect?: () => void;
@@ -128,8 +133,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
message_id: number;
ack_count: number;
paths?: MessagePath[];
packet_id?: number | null;
};
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
handlers.onMessageAcked?.(
ackData.message_id,
ackData.ack_count,
ackData.paths,
ackData.packet_id
);
break;
}
case 'error':

View File

@@ -163,10 +163,17 @@ function getSourceInfo(
case PayloadType.TextMessage:
case PayloadType.Request:
case PayloadType.Response: {
const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
if (contactKey) {
return {
sourceKey: contactKey,
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey),
};
}
const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash;
if (!sourceHash) return { sourceKey: null, sourceLabel: null };
return {
sourceKey: sourceHash.toUpperCase(),
sourceKey: `hash1:${sourceHash.toUpperCase()}`,
sourceLabel: sourceHash.toUpperCase(),
};
}

View File

@@ -4,6 +4,7 @@ export interface MessageAckedPayload {
message_id: number;
ack_count: number;
paths?: MessagePath[];
packet_id?: number | null;
}
export interface ContactDeletedPayload {

View File

@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.4.1"
version = "3.6.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ dependencies = [
"httpx>=0.28.1",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore==2.3.1",
"meshcore==2.3.2",
"aiomqtt>=2.0",
"apprise>=1.9.7",
"boto3>=1.38.0",

View File

@@ -12,8 +12,8 @@ export default defineConfig({
timeout: 60_000,
expect: { timeout: 15_000 },
// Don't retry — failures likely indicate real hardware/app issues
retries: 0,
// Give hardware-backed flows one automatic retry before marking the test failed.
retries: 1,
// Run tests serially — single radio means no parallelism
fullyParallel: false,

View File

@@ -6,9 +6,9 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Find a named non-repeater contact (type 2 = repeater). */
/** Find a named normal chat contact (exclude repeaters and room servers). */
function findChatContact(contacts: Contact[]): Contact | undefined {
return contacts.find((c) => c.name && c.name.trim().length > 0 && c.type !== 2);
return contacts.find((c) => c.name && c.name.trim().length > 0 && c.type !== 2 && c.type !== 3);
}
test.describe('Contacts sidebar & info pane', () => {

View File

@@ -160,6 +160,35 @@ class TestDebugEndpoint:
assert payload["radio_probe"]["performed"] is False
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
assert payload["database"]["total_outgoing"] == 0
@pytest.mark.asyncio
async def test_support_snapshot_includes_database_message_totals(self, test_db, client):
"""Debug snapshot includes stored DM/channel/outgoing totals."""
await _insert_contact("ab" * 32, "Alice")
await test_db.conn.execute(
"INSERT INTO channels (key, name, is_hashtag, on_radio) VALUES (?, ?, ?, ?)",
("CD" * 16, "#ops", 1, 0),
)
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
("PRIV", "ab" * 32, "hello", 1000, 0),
)
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
("CHAN", "CD" * 16, "room msg", 1001, 1),
)
await test_db.conn.commit()
response = await client.get("/api/debug")
assert response.status_code == 200
payload = response.json()
assert payload["database"]["total_dms"] == 1
assert payload["database"]["total_channel_messages"] == 1
assert payload["database"]["total_outgoing"] == 1
class TestRadioDisconnectedHandler:

View File

@@ -1131,7 +1131,7 @@ class TestMessageAckedBroadcastShape:
# Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117)
# The 'paths' key is optional in the TypeScript interface
REQUIRED_KEYS = {"message_id", "ack_count"}
OPTIONAL_KEYS = {"paths"}
OPTIONAL_KEYS = {"paths", "packet_id"}
@pytest.mark.asyncio
async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts):
@@ -1177,6 +1177,7 @@ class TestMessageAckedBroadcastShape:
assert isinstance(payload["ack_count"], int)
assert payload["message_id"] == msg_id
assert payload["ack_count"] == 1
assert payload["packet_id"] == pkt_id
# paths should be a list of dicts with path and received_at keys
assert isinstance(payload["paths"], list)
@@ -1228,6 +1229,7 @@ class TestMessageAckedBroadcastShape:
assert payload_keys >= self.REQUIRED_KEYS
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
assert payload["ack_count"] == 0 # Not outgoing, no ack increment
assert payload["packet_id"] == pkt1
@pytest.mark.asyncio
async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts):
@@ -1283,3 +1285,4 @@ class TestMessageAckedBroadcastShape:
assert isinstance(payload["message_id"], int)
assert isinstance(payload["ack_count"], int)
assert payload["ack_count"] == 0 # Outgoing DM duplicates no longer count as delivery
assert payload["packet_id"] == pkt1

View File

@@ -18,6 +18,8 @@ from app.event_handlers import (
track_pending_ack,
)
from app.repository import (
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
)
@@ -382,6 +384,7 @@ class TestContactMessageCLIFiltering:
"acked",
"sender_name",
"channel_name",
"packet_id",
}
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
@@ -618,6 +621,8 @@ class TestContactMessageCLIFiltering:
sender_timestamp=1700000000,
received_at=1700000000,
)
await ContactNameHistoryRepository.record_name(prefix, "Prefix Sender", 1699999990)
await ContactAdvertPathRepository.record_observation(prefix, "1122", 1699999995)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
@@ -646,6 +651,19 @@ class TestContactMessageCLIFiltering:
assert len(messages) == 1
assert messages[0].conversation_key == full_key
assert await ContactNameHistoryRepository.get_history(prefix) == []
assert await ContactAdvertPathRepository.get_recent_for_contact(prefix) == []
resolved_history = await ContactNameHistoryRepository.get_history(full_key)
assert {entry.name for entry in resolved_history} == {
"Prefix Sender",
"Resolved Sender",
}
resolved_paths = await ContactAdvertPathRepository.get_recent_for_contact(full_key)
assert len(resolved_paths) == 1
assert resolved_paths[0].path == "1122"
event_types = [call.args[0] for call in mock_broadcast.call_args_list]
assert "contact" in event_types
assert "contact_resolved" in event_types

View File

@@ -1247,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 45
assert applied == 8
assert await get_version(conn) == 46
cursor = await conn.execute(
"""
@@ -1319,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 45
assert applied == 8
assert await get_version(conn) == 46
cursor = await conn.execute(
"""
@@ -1386,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 45
assert applied == 2
assert await get_version(conn) == 46
cursor = await conn.execute(
"""
@@ -1439,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 45
assert applied == 7
assert await get_version(conn) == 46
await conn.execute(
"""
@@ -1501,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 45
assert applied == 6
assert await get_version(conn) == 46
await conn.execute(
"""
@@ -1554,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 45
assert applied == 5
assert await get_version(conn) == 46
await conn.execute(
"""
@@ -1577,6 +1577,179 @@ class TestMigration042:
await conn.close()
class TestMigration046:
"""Test migration 046: clean orphaned contact child rows."""
@pytest.mark.asyncio
async def test_merges_uniquely_resolvable_orphans_and_drops_unresolved_ones(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 45)
await conn.execute("""
CREATE TABLE contacts (
public_key TEXT PRIMARY KEY,
name TEXT
)
""")
await conn.execute("""
CREATE TABLE contact_name_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name)
)
""")
await conn.execute("""
CREATE TABLE contact_advert_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len)
)
""")
resolved_prefix = "abc123"
resolved_key = resolved_prefix + ("00" * 29)
ambiguous_prefix = "deadbe"
ambiguous_key_a = ambiguous_prefix + ("11" * 29)
ambiguous_key_b = ambiguous_prefix + ("22" * 29)
dead_prefix = "ffffaa"
await conn.execute(
"INSERT INTO contacts (public_key, name) VALUES (?, ?), (?, ?), (?, ?)",
(
resolved_key,
"Resolved Sender",
ambiguous_key_a,
"Ambiguous A",
ambiguous_key_b,
"Ambiguous B",
),
)
await conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)
""",
(
resolved_key,
"Resolved Sender",
900,
905,
resolved_prefix,
"Prefix Sender",
1000,
1010,
ambiguous_prefix,
"Ambiguous Prefix",
1100,
1110,
),
)
await conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
VALUES
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?)
""",
(
resolved_key,
"1122",
1,
950,
960,
2,
resolved_prefix,
"1122",
1,
1001,
1002,
3,
ambiguous_prefix,
"3344",
2,
1200,
1201,
1,
dead_prefix,
"5566",
1,
1300,
1301,
1,
),
)
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 46
cursor = await conn.execute(
"""
SELECT name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ORDER BY name
""",
(resolved_key,),
)
rows = await cursor.fetchall()
assert [(row["name"], row["first_seen"], row["last_seen"]) for row in rows] == [
("Prefix Sender", 1000, 1010),
("Resolved Sender", 900, 905),
]
cursor = await conn.execute(
"""
SELECT path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ORDER BY path_hex, path_len
""",
(resolved_key,),
)
rows = await cursor.fetchall()
assert [
(
row["path_hex"],
row["path_len"],
row["first_seen"],
row["last_seen"],
row["heard_count"],
)
for row in rows
] == [
("1122", 1, 950, 1002, 5),
]
for orphan_key in (resolved_prefix, ambiguous_prefix, dead_prefix):
cursor = await conn.execute(
"SELECT COUNT(*) FROM contact_name_history WHERE public_key = ?",
(orphan_key,),
)
assert (await cursor.fetchone())[0] == 0
cursor = await conn.execute(
"SELECT COUNT(*) FROM contact_advert_paths WHERE public_key = ?",
(orphan_key,),
)
assert (await cursor.fetchone())[0] == 0
finally:
await conn.close()
class TestMigrationPacketHelpers:
"""Test migration-local packet helpers against canonical path validation."""

View File

@@ -56,6 +56,48 @@ class TestUndecryptedCount:
assert response.json()["count"] == 3
class TestGetRawPacket:
"""Test GET /api/packets/{id}."""
@pytest.mark.asyncio
async def test_returns_404_when_missing(self, test_db, client):
response = await client.get("/api/packets/999999")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_returns_linked_packet_details(self, test_db, client):
channel_key = "DEADBEEF" * 4
await ChannelRepository.upsert(key=channel_key, name="#ops", is_hashtag=False)
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test-packet", 1700000000)
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="Alice: hello",
conversation_key=channel_key,
sender_timestamp=1700000000,
received_at=1700000000,
sender_name="Alice",
)
assert msg_id is not None
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
response = await client.get(f"/api/packets/{packet_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == packet_id
assert data["timestamp"] == 1700000000
assert data["data"] == "0900746573742d7061636b6574"
assert data["decrypted"] is True
assert data["decrypted_info"] == {
"channel_name": "#ops",
"sender": "Alice",
"channel_key": channel_key,
"contact_key": None,
}
class TestDecryptHistoricalPackets:
"""Test POST /api/packets/decrypt/historical."""

View File

@@ -1521,10 +1521,10 @@ class TestConcurrentChannelSends:
class TestChannelSendLockScope:
"""Channel send should release the radio lock before DB persistence work."""
"""Channel send should persist the outgoing row while the radio lock is held."""
@pytest.mark.asyncio
async def test_channel_message_row_created_after_radio_lock_released(self, test_db):
async def test_channel_message_row_created_inside_radio_lock(self, test_db):
mc = _make_mc(name="TestNode")
chan_key = "de" * 16
await ChannelRepository.upsert(key=chan_key, name="#lockscope")
@@ -1549,4 +1549,66 @@ class TestChannelSendLockScope:
SendChannelMessageRequest(channel_key=chan_key, text="Lock scope test")
)
assert observed_lock_states == [False]
assert observed_lock_states == [True]
@pytest.mark.asyncio
async def test_channel_self_observation_during_send_reconciles_to_reserved_outgoing_row(
self, test_db
):
"""A self-observation that arrives during send should update the reserved outgoing row."""
from app.services.messages import create_fallback_channel_message
mc = _make_mc(name="TestNode")
chan_key = "ef" * 16
await ChannelRepository.upsert(key=chan_key, name="#race")
broadcasts = []
def capture_broadcast(event_type, data, *args, **kwargs):
broadcasts.append({"type": event_type, "data": data})
async def send_with_self_observation(*args, **kwargs):
timestamp_bytes = kwargs["timestamp"]
sender_timestamp = int.from_bytes(timestamp_bytes, "little")
await create_fallback_channel_message(
conversation_key=chan_key.upper(),
message_text="Hello race",
sender_timestamp=sender_timestamp,
received_at=int(time.time()),
path="a1b2",
path_len=2,
txt_type=0,
sender_name="TestNode",
channel_name="#race",
broadcast_fn=capture_broadcast,
)
return _make_radio_result()
mc.commands.send_chan_msg = AsyncMock(side_effect=send_with_self_observation)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
message = await send_channel_message(
SendChannelMessageRequest(channel_key=chan_key, text="Hello race")
)
assert message.outgoing is True
assert message.acked == 1
assert message.paths is not None
assert len(message.paths) == 1
assert message.paths[0].path == "a1b2"
stored = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=chan_key.upper(), limit=10
)
assert len(stored) == 1
assert stored[0].outgoing is True
assert stored[0].acked == 1
message_events = [entry for entry in broadcasts if entry["type"] == "message"]
ack_events = [entry for entry in broadcasts if entry["type"] == "message_acked"]
assert len(message_events) == 1
assert len(ack_events) == 1

View File

@@ -29,6 +29,9 @@ class TestStatisticsEmpty:
assert result["repeaters_heard"]["last_hour"] == 0
assert result["repeaters_heard"]["last_24_hours"] == 0
assert result["repeaters_heard"]["last_week"] == 0
assert result["known_channels_active"]["last_hour"] == 0
assert result["known_channels_active"]["last_24_hours"] == 0
assert result["known_channels_active"]["last_week"] == 0
assert result["path_hash_width_24h"] == {
"total_packets": 0,
"single_byte": 0,
@@ -256,6 +259,51 @@ class TestActivityWindows:
assert result["repeaters_heard"]["last_24_hours"] == 1
assert result["repeaters_heard"]["last_week"] == 1
@pytest.mark.asyncio
async def test_known_channels_active_windows(self, test_db):
"""Known channels are counted by distinct active keys in each time window."""
now = int(time.time())
conn = test_db.conn
known_1h = "AA" * 16
known_24h = "BB" * 16
known_7d = "CC" * 16
unknown_key = "DD" * 16
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_1h, "chan-1h"))
await conn.execute(
"INSERT INTO channels (key, name) VALUES (?, ?)", (known_24h, "chan-24h")
)
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_7d, "chan-7d"))
await conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
("CHAN", known_1h, "recent-1", now - 1200),
)
await conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
("CHAN", known_1h, "recent-2", now - 600),
)
await conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
("CHAN", known_24h, "day-old", now - 43200),
)
await conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
("CHAN", known_7d, "week-old", now - 259200),
)
await conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
("CHAN", unknown_key, "unknown", now - 600),
)
await conn.commit()
result = await StatisticsRepository.get_all()
assert result["known_channels_active"]["last_hour"] == 1
assert result["known_channels_active"]["last_24_hours"] == 2
assert result["known_channels_active"]["last_week"] == 3
class TestPathHashWidthStats:
@pytest.mark.asyncio

10
uv.lock generated
View File

@@ -534,7 +534,7 @@ wheels = [
[[package]]
name = "meshcore"
version = "2.3.1"
version = "2.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bleak" },
@@ -542,9 +542,9 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "pyserial-asyncio-fast" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/a8/79f84f32cad056358b1e31dbb343d7f986f78fd93021dbbde306a9b4d36e/meshcore-2.3.1.tar.gz", hash = "sha256:07bd2267cb84a335b915ea6dab1601ae7ae13cad5923793e66b2356c3e351e24", size = 69503 }
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/df/66d615298b717c2c6471592e2b96117f391ae3c99f477d7f424449897bf0/meshcore-2.3.1-py3-none-any.whl", hash = "sha256:59bb8b66fd9e3261dbdb0e69fc038d4606bfd4ad1a260bbdd8659066e4bf12d2", size = 53084 },
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
]
[[package]]
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.4.1"
version = "3.6.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -1142,7 +1142,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
{ name = "meshcore", specifier = "==2.3.1" },
{ name = "meshcore", specifier = "==2.3.2" },
{ name = "pycryptodome", specifier = ">=3.20.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pynacl", specifier = ">=1.5.0" },