Better error cards for private messages, neighbor info rendering

This commit is contained in:
Daniel Pupius
2025-06-23 09:47:14 -07:00
parent a83f4feddb
commit dc36070355
9 changed files with 275 additions and 6 deletions

3
.gitignore vendored
View File

@@ -75,4 +75,7 @@ temp/
# Binary output type
#meshstream
# AI Agent files
**/.claude/settings.local.json
.private-journal/*

View File

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

View File

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

View File

@@ -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<NodeDetailProps> = ({ nodeId }) => {
/>
)}
{node.macAddr && (
{node.isLicensed && (
<KeyValuePair
label="MAC Address"
value={node.macAddr}
label="Licensed Operator"
value="Yes"
icon={<Shield className="w-3 h-3" />}
highlight={true}
inset={true}
/>
)}
{node.role && (
<KeyValuePair
label="Role"
value={formatRole(node.role)}
icon={<UserCheck className="w-3 h-3" />}
monospace={true}
inset={true}
/>
)}
{node.publicKey && (
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Key className="w-3 h-3 mr-2 text-neutral-300" />
Public Key
</span>
<div className="flex items-center space-x-2">
<span className="font-mono text-sm text-neutral-200">
{formatPublicKey(node.publicKey)}
</span>
<button
onClick={() => copyToClipboard(node.publicKey!)}
className="p-1 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-600 rounded transition-colors"
title="Copy full public key"
>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
)}
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Wifi className="w-3 h-3 mr-2 text-neutral-300" />

View File

@@ -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<NeighborInfoPacketProps> = ({ 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 (
<PacketCard
packet={packet}
icon={<Network />}
iconBgColor="bg-blue-500"
label="Neighbor Info"
>
<div className="flex flex-col gap-3">
{/* Header with reporting node info */}
<div className="flex items-center gap-2 text-sm">
<span className="text-neutral-400">Reporting node:</span>
<Link
to="/node/$nodeId"
params={{ nodeId: neighborInfo.nodeId.toString(16) }}
className="text-blue-400 hover:text-blue-300 transition-colors font-mono"
>
{getNodeName(neighborInfo.nodeId)}
</Link>
{neighborInfo.nodeBroadcastIntervalSecs && (
<span className="text-neutral-500 text-xs">
(broadcasts every {formatInterval(neighborInfo.nodeBroadcastIntervalSecs)})
</span>
)}
</div>
{/* Neighbors list */}
{neighborInfo.neighbors && neighborInfo.neighbors.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="text-sm text-neutral-400">
{neighborInfo.neighbors.length} neighbor{neighborInfo.neighbors.length !== 1 ? 's' : ''}:
</div>
<div className="grid gap-2">
{neighborInfo.neighbors.map((neighbor, index) => (
<div key={index} className="bg-neutral-800/50 rounded p-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link
to="/node/$nodeId"
params={{ nodeId: neighbor.nodeId.toString(16) }}
className="text-blue-400 hover:text-blue-300 transition-colors font-mono flex items-center gap-1"
>
{getNodeName(neighbor.nodeId)}
<ExternalLink className="w-3 h-3" />
</Link>
</div>
<div className="flex items-center gap-3 text-xs text-neutral-400">
<div>
<span className="text-neutral-500 mr-1">SNR:</span>
<span className={neighbor.snr > 0 ? "text-green-400" : neighbor.snr > -10 ? "text-yellow-400" : "text-red-400"}>
{neighbor.snr.toFixed(1)}dB
</span>
</div>
{neighbor.lastRxTime && (
<div>
<span className="text-neutral-500 mr-1">Last:</span>
<span>{formatTime(neighbor.lastRxTime)}</span>
</div>
)}
{neighbor.nodeBroadcastIntervalSecs && (
<div>
<span className="text-neutral-500 mr-1">Interval:</span>
<span>{formatInterval(neighbor.nodeBroadcastIntervalSecs)}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-sm text-neutral-500 italic">No neighbors reported</div>
)}
</div>
</PacketCard>
);
};

View File

@@ -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<PacketRendererProps> = ({ 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 <PrivateMessagePacket packet={packet} />;
}
return <ErrorPacket packet={packet} />;
}
@@ -49,6 +54,9 @@ export const PacketRenderer: React.FC<PacketRendererProps> = ({ packet }) => {
case PortNum.TRACEROUTE_APP:
return <TraceroutePacket packet={packet} />;
case PortNum.NEIGHBORINFO_APP:
return <NeighborInfoPacket packet={packet} />;
default:
return <GenericPacket packet={packet} />;

View File

@@ -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<PrivateMessagePacketProps> = ({ 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 (
<PacketCard
packet={packet}
icon={<Lock />}
iconBgColor="bg-gray-500"
label="Private Message"
>
<div className="flex flex-col gap-3">
<div className="text-gray-300 text-sm flex items-center gap-2">
<Lock className="w-4 h-4 text-gray-400" />
<span>This message was encrypted for channel</span>
<span className="font-mono text-gray-100 bg-gray-700/50 px-2 py-0.5 rounded">
{channelName}
</span>
</div>
<div className="text-xs text-gray-400 bg-gray-800/50 p-3 rounded border border-gray-700/30">
This meshstream instance is not configured with the decryption key for this channel.
The message content remains private and encrypted.
</div>
{data.binaryData && (
<details className="mt-2">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-400 transition-colors">
Show encrypted data
</summary>
<div className="mt-2 font-mono text-neutral-300 text-xs bg-neutral-800/80 p-3 rounded-md overflow-auto tracking-wide leading-relaxed border border-neutral-700/40">
{data.binaryData}
</div>
</details>
)}
</div>
</PacketCard>
);
};

View File

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

View File

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