10 Commits
3.5.0 ... 3.6.0

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
38 changed files with 913 additions and 252 deletions

View File

@@ -1,3 +1,11 @@
## [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

View File

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

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

@@ -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

@@ -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()
@@ -570,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

@@ -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

@@ -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,
)
@@ -131,6 +133,7 @@ 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."""
@@ -140,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,
},
)
@@ -182,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,
)
@@ -307,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,

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.5.0",
"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

@@ -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,
@@ -385,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(() => {
@@ -418,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">
@@ -428,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">
@@ -599,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

@@ -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

@@ -86,7 +86,12 @@ export class ConversationMessageCache {
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;
@@ -96,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;
@@ -146,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,
});
}
@@ -162,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;
@@ -180,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 }),
};
}
@@ -199,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 }),
};
}
@@ -206,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 {
@@ -230,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;
@@ -291,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);
@@ -319,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[]>([]);
@@ -782,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;
}
@@ -807,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;
});
},
@@ -818,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

@@ -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',

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

@@ -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.5.0"
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

@@ -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

@@ -384,6 +384,7 @@ class TestContactMessageCLIFiltering:
"acked",
"sender_name",
"channel_name",
"packet_id",
}
with patch("app.event_handlers.broadcast_event") as mock_broadcast:

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

@@ -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.5.0"
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" },