From dc360703559364a250847b36a4b2c31a7c0b14d0 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Mon, 23 Jun 2025 09:47:14 -0700 Subject: [PATCH] Better error cards for private messages, neighbor info rendering --- .gitignore | 3 + decoder/decoder.go | 7 +- decoder/keys.go | 9 ++ web/src/components/dashboard/NodeDetail.tsx | 74 ++++++++++- .../components/packets/NeighborInfoPacket.tsx | 116 ++++++++++++++++++ web/src/components/packets/PacketRenderer.tsx | 10 +- .../packets/PrivateMessagePacket.tsx | 51 ++++++++ web/src/lib/types.ts | 4 +- web/src/store/slices/aggregatorSlice.ts | 7 ++ 9 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 web/src/components/packets/NeighborInfoPacket.tsx create mode 100644 web/src/components/packets/PrivateMessagePacket.tsx diff --git a/.gitignore b/.gitignore index e882abb..60b3e00 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,7 @@ temp/ # Binary output type #meshstream + +# AI Agent files **/.claude/settings.local.json +.private-journal/* \ No newline at end of file diff --git a/decoder/decoder.go b/decoder/decoder.go index 2d6e377..bc4e31c 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -308,7 +308,12 @@ func decodeEncryptedPayload(data *meshtreampb.Data, encrypted []byte, channelId TextMessage: string(decrypted), } } else { - data.DecodeError = fmt.Sprintf("failed to parse decrypted data: %v", err) + // Check if this channel is configured - if not, likely a private message + if !IsChannelConfigured(channelId) { + data.DecodeError = fmt.Sprintf("PRIVATE_CHANNEL: failed to parse decrypted data on unconfigured channel '%s': %v", channelId, err) + } else { + data.DecodeError = fmt.Sprintf("PARSE_ERROR: failed to parse decrypted data: %v", err) + } data.Payload = &meshtreampb.Data_BinaryData{ BinaryData: decrypted, } diff --git a/decoder/keys.go b/decoder/keys.go index c5d00d4..77d1356 100644 --- a/decoder/keys.go +++ b/decoder/keys.go @@ -72,6 +72,15 @@ func RemoveChannelKey(channelId string) { delete(channelKeys, channelId) } +// IsChannelConfigured checks if a channel has a specific key configured +func IsChannelConfigured(channelId string) bool { + channelKeysMutex.RLock() + defer channelKeysMutex.RUnlock() + + _, ok := channelKeys[channelId] + return ok +} + // PadKey ensures the key is properly padded to be a valid AES key length (16, 24, or 32 bytes) func PadKey(key []byte) []byte { // If key length is already valid, return as is diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index aa29e46..5772a23 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -22,6 +22,10 @@ import { MessageSquare, Thermometer, BoomBox, + Shield, + Key, + Copy, + UserCheck, } from "lucide-react"; import { Separator } from "../Separator"; import { KeyValuePair } from "../ui/KeyValuePair"; @@ -39,6 +43,38 @@ import { getRegionName, getModemPresetName, } from "../../utils/formatters"; + +// Format role string for display +const formatRole = (role?: string): string => { + if (!role) return "Unknown"; + return role; +}; + +// Format public key for display with copy functionality +const formatPublicKey = (publicKey?: string): string => { + if (!publicKey) return ""; + // Show first 8 and last 8 characters with ellipsis + if (publicKey.length > 16) { + return `${publicKey.slice(0, 8)}...${publicKey.slice(-8)}`; + } + return publicKey; +}; + +// Copy to clipboard function +const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } +}; + import { SignalStrength } from "./SignalStrength"; interface NodeDetailProps { @@ -226,15 +262,47 @@ export const NodeDetail: React.FC = ({ nodeId }) => { /> )} - {node.macAddr && ( + {node.isLicensed && ( } + highlight={true} + inset={true} + /> + )} + + {node.role && ( + } monospace={true} inset={true} /> )} + {node.publicKey && ( +
+ + + Public Key + +
+ + {formatPublicKey(node.publicKey)} + + +
+
+ )} +
diff --git a/web/src/components/packets/NeighborInfoPacket.tsx b/web/src/components/packets/NeighborInfoPacket.tsx new file mode 100644 index 0000000..d453c5b --- /dev/null +++ b/web/src/components/packets/NeighborInfoPacket.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { Link } from "@tanstack/react-router"; +import { Packet } from "../../lib/types"; +import { Network, ExternalLink } from "lucide-react"; +import { PacketCard } from "./PacketCard"; +import { useAppSelector } from "../../hooks"; + +interface NeighborInfoPacketProps { + packet: Packet; +} + +export const NeighborInfoPacket: React.FC = ({ packet }) => { + const { data } = packet; + const neighborInfo = data.neighborInfo; + const { nodes } = useAppSelector((state) => state.aggregator); + + if (!neighborInfo) { + return null; + } + + const formatNodeId = (nodeId: number): string => { + return `!${nodeId.toString(16).toLowerCase()}`; + }; + + const getNodeName = (nodeId: number): string => { + const node = nodes[nodeId]; + return node?.shortName || node?.longName || formatNodeId(nodeId); + }; + + const formatTime = (timestamp?: number): string => { + if (!timestamp) return "Unknown"; + return new Date(timestamp * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + }; + + const formatInterval = (seconds?: number): string => { + if (!seconds) return "Unknown"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + return `${Math.round(seconds / 3600)}h`; + }; + + return ( + } + iconBgColor="bg-blue-500" + label="Neighbor Info" + > +
+ {/* Header with reporting node info */} +
+ Reporting node: + + {getNodeName(neighborInfo.nodeId)} + + {neighborInfo.nodeBroadcastIntervalSecs && ( + + (broadcasts every {formatInterval(neighborInfo.nodeBroadcastIntervalSecs)}) + + )} +
+ + {/* Neighbors list */} + {neighborInfo.neighbors && neighborInfo.neighbors.length > 0 ? ( +
+
+ {neighborInfo.neighbors.length} neighbor{neighborInfo.neighbors.length !== 1 ? 's' : ''}: +
+
+ {neighborInfo.neighbors.map((neighbor, index) => ( +
+
+ + {getNodeName(neighbor.nodeId)} + + +
+
+
+ SNR: + 0 ? "text-green-400" : neighbor.snr > -10 ? "text-yellow-400" : "text-red-400"}> + {neighbor.snr.toFixed(1)}dB + +
+ {neighbor.lastRxTime && ( +
+ Last: + {formatTime(neighbor.lastRxTime)} +
+ )} + {neighbor.nodeBroadcastIntervalSecs && ( +
+ Interval: + {formatInterval(neighbor.nodeBroadcastIntervalSecs)} +
+ )} +
+
+ ))} +
+
+ ) : ( +
No neighbors reported
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/PacketRenderer.tsx b/web/src/components/packets/PacketRenderer.tsx index 6d511a6..a5fa63d 100644 --- a/web/src/components/packets/PacketRenderer.tsx +++ b/web/src/components/packets/PacketRenderer.tsx @@ -8,6 +8,8 @@ import { ErrorPacket } from "./ErrorPacket"; import { WaypointPacket } from "./WaypointPacket"; import { MapReportPacket } from "./MapReportPacket"; import { TraceroutePacket } from "./TraceroutePacket"; +import { NeighborInfoPacket } from "./NeighborInfoPacket"; +import { PrivateMessagePacket } from "./PrivateMessagePacket"; import { GenericPacket } from "./GenericPacket"; interface PacketRendererProps { @@ -17,8 +19,11 @@ interface PacketRendererProps { export const PacketRenderer: React.FC = ({ packet }) => { const { data } = packet; - // If there's a decode error, show the error packet + // If there's a decode error, check the error type if (data.decodeError) { + if (data.decodeError.startsWith('PRIVATE_CHANNEL:')) { + return ; + } return ; } @@ -49,6 +54,9 @@ export const PacketRenderer: React.FC = ({ packet }) => { case PortNum.TRACEROUTE_APP: return ; + + case PortNum.NEIGHBORINFO_APP: + return ; default: return ; diff --git a/web/src/components/packets/PrivateMessagePacket.tsx b/web/src/components/packets/PrivateMessagePacket.tsx new file mode 100644 index 0000000..d6626c9 --- /dev/null +++ b/web/src/components/packets/PrivateMessagePacket.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { Lock } from "lucide-react"; +import { PacketCard } from "./PacketCard"; + +interface PrivateMessagePacketProps { + packet: Packet; +} + +export const PrivateMessagePacket: React.FC = ({ packet }) => { + const { data } = packet; + + // Extract channel name from error message if available + const channelMatch = data.decodeError?.match(/channel '([^']+)'/); + const channelName = channelMatch?.[1] || packet.info.channel || "unknown"; + + return ( + } + iconBgColor="bg-gray-500" + label="Private Message" + > +
+
+ + This message was encrypted for channel + + {channelName} + +
+ +
+ This meshstream instance is not configured with the decryption key for this channel. + The message content remains private and encrypted. +
+ + {data.binaryData && ( +
+ + Show encrypted data + +
+ {data.binaryData} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 82d749e..e3e0b44 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -101,13 +101,15 @@ export interface User { macaddr?: string; // MAC address of the device hwModel?: string; // Hardware model name hasGps?: boolean; // Whether the node has GPS capability - role?: string; // User's role in the mesh (e.g., "ROUTER") + role?: string; // User's role in the mesh (e.g., "CLIENT", "ROUTER") snr?: number; // Signal-to-noise ratio batteryLevel?: number; // Battery level 0-100 voltage?: number; // Battery voltage channelUtilization?: number; // Channel utilization percentage airUtilTx?: number; // Air utilization for transmission lastHeard?: number; // Last time the node was heard from + isLicensed?: boolean; // Whether the user is a licensed ham radio operator + publicKey?: string; // The public key of the user's device } // Device metrics for telemetry diff --git a/web/src/store/slices/aggregatorSlice.ts b/web/src/store/slices/aggregatorSlice.ts index 8e31d64..db6a891 100644 --- a/web/src/store/slices/aggregatorSlice.ts +++ b/web/src/store/slices/aggregatorSlice.ts @@ -31,6 +31,10 @@ export interface NodeData { observedNodeCount?: number; // MapReport payload for this node mapReport?: MapReport; + // User-specific fields + isLicensed?: boolean; + role?: string; + publicKey?: string; } export interface TextMessage { @@ -359,6 +363,9 @@ const updateNodeInfo = (node: NodeData, nodeInfo: User) => { if (nodeInfo.batteryLevel !== undefined) node.batteryLevel = nodeInfo.batteryLevel; if (nodeInfo.snr !== undefined) node.snr = nodeInfo.snr; + if (nodeInfo.isLicensed !== undefined) node.isLicensed = nodeInfo.isLicensed; + if (nodeInfo.role) node.role = nodeInfo.role; + if (nodeInfo.publicKey) node.publicKey = nodeInfo.publicKey; }; // Helper to update telemetry data