mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Better error cards for private messages, neighbor info rendering
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,4 +75,7 @@ temp/
|
||||
|
||||
# Binary output type
|
||||
#meshstream
|
||||
|
||||
# AI Agent files
|
||||
**/.claude/settings.local.json
|
||||
.private-journal/*
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
116
web/src/components/packets/NeighborInfoPacket.tsx
Normal file
116
web/src/components/packets/NeighborInfoPacket.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
|
||||
51
web/src/components/packets/PrivateMessagePacket.tsx
Normal file
51
web/src/components/packets/PrivateMessagePacket.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user