Use generated ConnectRPC types in the web UI; satisfy buf lint

Remove the snake_case mirror interfaces and mapper functions that translated
generated protobuf-es messages for the components, and have the UI consume the
generated types directly (single source of truth = the proto schema).

- Hooks (useStats/useNeighbors/useNodeData/useMeshcoreSearch/useChatMessages)
  return generated message types; delete toNodeData/toChatMessage/toSearchResult
  and the snake_case select/.map adapters. Delete the dead useAllNeighbors hook
  and src/types/map.ts; components import NodePosition/Neighbor/NeighborEdge/
  Advert/SearchResult/ChatMessage from src/gen.
- Components read camelCase fields (node page, AdvertDetails, MapView, MapIcons,
  stats page, search results, chat). useNodeData surfaces ConnectError with a
  nodeErrorCode() helper for the error UI. Drop the always-empty Alt row in the
  map popup (generated NodePosition has no altitude).
- buf lint (Option A): give each StatsService RPC its own request message, and
  wrap the server-streaming responses (StreamChatResponse / StreamPacketsResponse).
  Server stream handlers + the chat stream consumer updated accordingly.
- Add a buf CI workflow (lint/format/breaking on proto changes).

tsc, next build, and buf lint are clean; verified end-to-end in Docker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-05-29 04:38:11 -04:00
parent da838d679a
commit 1e456f04b8
28 changed files with 363 additions and 710 deletions
+36
View File
@@ -0,0 +1,36 @@
name: buf
on:
pull_request:
paths:
- "meshexplorer/proto/**"
- "meshexplorer/buf.yaml"
- "meshexplorer/buf.gen.yaml"
- "meshexplorer/buf.lock"
- ".github/workflows/buf.yaml"
push:
branches:
- main
paths:
- "meshexplorer/proto/**"
- "meshexplorer/buf.yaml"
- "meshexplorer/buf.gen.yaml"
- "meshexplorer/buf.lock"
permissions:
contents: read
pull-requests: write # lets buf-action post check summaries as PR comments
jobs:
buf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
input: meshexplorer # buf.yaml lives in this subdir
lint: true
format: true
breaking: true
push: false # no BSR publishing / token
github_token: ${{ github.token }}
+17 -4
View File
@@ -37,7 +37,10 @@ message ChatMessage {
}
message GetChatRequest {
optional int32 limit = 1 [(buf.validate.field).int32 = {gte: 1, lte: 1000}];
optional int32 limit = 1 [(buf.validate.field).int32 = {
gte: 1
lte: 1000
}];
optional string before = 2;
optional string after = 3;
optional string channel_id = 4 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
@@ -56,13 +59,23 @@ message StreamChatRequest {
bool decrypt = 3;
repeated string private_keys = 4;
// Poll interval in ms (clamped 100..10000, default 1000).
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {gte: 100, lte: 10000}];
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {
gte: 100
lte: 10000
}];
// Max rows per poll (clamped 10..1000, default 500).
optional int32 max_rows = 6 [(buf.validate.field).int32 = {gte: 10, lte: 1000}];
optional int32 max_rows = 6 [(buf.validate.field).int32 = {
gte: 10
lte: 1000
}];
bool skip_initial_messages = 7;
}
message StreamChatResponse {
ChatMessage message = 1;
}
service ChatService {
rpc GetChat(GetChatRequest) returns (GetChatResponse);
rpc StreamChat(StreamChatRequest) returns (stream ChatMessage);
rpc StreamChat(StreamChatRequest) returns (stream StreamChatResponse);
}
+16 -4
View File
@@ -20,10 +20,22 @@ message NodePosition {
message GetMapRequest {
// Bounding box (decimal degrees). Unset fields mean "unbounded" on that edge.
optional double min_lat = 1 [(buf.validate.field).double = {gte: -90, lte: 90}];
optional double max_lat = 2 [(buf.validate.field).double = {gte: -90, lte: 90}];
optional double min_lng = 3 [(buf.validate.field).double = {gte: -180, lte: 180}];
optional double max_lng = 4 [(buf.validate.field).double = {gte: -180, lte: 180}];
optional double min_lat = 1 [(buf.validate.field).double = {
gte: -90
lte: 90
}];
optional double max_lat = 2 [(buf.validate.field).double = {
gte: -90
lte: 90
}];
optional double min_lng = 3 [(buf.validate.field).double = {
gte: -180
lte: 180
}];
optional double max_lng = 4 [(buf.validate.field).double = {
gte: -180
lte: 180
}];
repeated string node_types = 5;
// Only include nodes seen within this many seconds.
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
@@ -6,10 +6,22 @@ import "buf/validate/validate.proto";
import "meshexplorer/v1/common.proto";
message GetAllNeighborsRequest {
optional double min_lat = 1 [(buf.validate.field).double = {gte: -90, lte: 90}];
optional double max_lat = 2 [(buf.validate.field).double = {gte: -90, lte: 90}];
optional double min_lng = 3 [(buf.validate.field).double = {gte: -180, lte: 180}];
optional double max_lng = 4 [(buf.validate.field).double = {gte: -180, lte: 180}];
optional double min_lat = 1 [(buf.validate.field).double = {
gte: -90
lte: 90
}];
optional double max_lat = 2 [(buf.validate.field).double = {
gte: -90
lte: 90
}];
optional double min_lng = 3 [(buf.validate.field).double = {
gte: -180
lte: 180
}];
optional double max_lng = 4 [(buf.validate.field).double = {
gte: -180
lte: 180
}];
repeated string node_types = 5;
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
optional string region = 7;
@@ -67,7 +67,10 @@ message MqttInfo {
message GetNodeRequest {
string public_key = 1 [(buf.validate.field).string.min_len = 10];
// Max number of recent adverts to return (default 50).
optional int32 limit = 2 [(buf.validate.field).int32 = {gte: 1, lte: 1000}];
optional int32 limit = 2 [(buf.validate.field).int32 = {
gte: 1
lte: 1000
}];
}
message GetNodeResponse {
@@ -106,7 +109,10 @@ message SearchQuery {
optional string query = 1 [(buf.validate.field).string.max_len = 100];
optional string region = 2;
optional int32 last_seen = 3 [(buf.validate.field).int32.gte = 0];
optional int32 limit = 4 [(buf.validate.field).int32 = {gte: 1, lte: 200}];
optional int32 limit = 4 [(buf.validate.field).int32 = {
gte: 1
lte: 200
}];
optional bool exact = 5;
optional bool is_repeater = 6;
}
@@ -23,16 +23,32 @@ message Packet {
message StreamPacketsRequest {
optional string region = 1;
// Payload type filter (0..15).
optional int32 payload_type = 2 [(buf.validate.field).int32 = {gte: 0, lte: 15}];
optional int32 payload_type = 2 [(buf.validate.field).int32 = {
gte: 0
lte: 15
}];
// Route type filter (0..3).
optional int32 route_type = 3 [(buf.validate.field).int32 = {gte: 0, lte: 3}];
optional int32 route_type = 3 [(buf.validate.field).int32 = {
gte: 0
lte: 3
}];
optional string origin_pubkey = 4 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
// Poll interval in ms (clamped 100..10000, default 500).
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {gte: 100, lte: 10000}];
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {
gte: 100
lte: 10000
}];
// Max rows per poll (clamped 10..10000, default 10).
optional int32 max_rows = 6 [(buf.validate.field).int32 = {gte: 10, lte: 10000}];
optional int32 max_rows = 6 [(buf.validate.field).int32 = {
gte: 10
lte: 10000
}];
}
message StreamPacketsResponse {
Packet packet = 1;
}
service PacketsService {
rpc StreamPackets(StreamPacketsRequest) returns (stream Packet);
rpc StreamPackets(StreamPacketsRequest) returns (stream StreamPacketsResponse);
}
+17 -5
View File
@@ -2,7 +2,19 @@ syntax = "proto3";
package meshexplorer.v1;
message StatsRequest {
message GetTotalNodesRequest {
optional string region = 1;
}
message GetNodesOverTimeRequest {
optional string region = 1;
}
message GetPopularChannelsRequest {
optional string region = 1;
}
message GetRepeaterPrefixesRequest {
optional string region = 1;
}
@@ -42,8 +54,8 @@ message GetRepeaterPrefixesResponse {
}
service StatsService {
rpc GetTotalNodes(StatsRequest) returns (GetTotalNodesResponse);
rpc GetNodesOverTime(StatsRequest) returns (GetNodesOverTimeResponse);
rpc GetPopularChannels(StatsRequest) returns (GetPopularChannelsResponse);
rpc GetRepeaterPrefixes(StatsRequest) returns (GetRepeaterPrefixesResponse);
rpc GetTotalNodes(GetTotalNodesRequest) returns (GetTotalNodesResponse);
rpc GetNodesOverTime(GetNodesOverTimeRequest) returns (GetNodesOverTimeResponse);
rpc GetPopularChannels(GetPopularChannelsRequest) returns (GetPopularChannelsResponse);
rpc GetRepeaterPrefixes(GetRepeaterPrefixesRequest) returns (GetRepeaterPrefixesResponse);
}
@@ -8,8 +8,9 @@ import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils";
import AdvertDetails from "@/components/AdvertDetails";
import ContactQRCode from "@/components/ContactQRCode";
import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext";
import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors";
import { useNodeData, type NodeData, type NodeInfo, type Advert, type LocationHistory, type MqttInfo, type NodeError } from "@/hooks/useNodeData";
import { useNeighbors } from "@/hooks/useNeighbors";
import { useNodeData, nodeErrorCode } from "@/hooks/useNodeData";
import type { NodeInfo } from "@/gen/meshexplorer/v1/node_pb";
import { ArrowRightEndOnRectangleIcon, ArrowRightStartOnRectangleIcon } from "@heroicons/react/24/outline";
import { RegionProvider } from "@/contexts/RegionContext";
@@ -17,9 +18,9 @@ import { RegionProvider } from "@/contexts/RegionContext";
// Function to determine node type based on capabilities
function getNodeType(node: NodeInfo): number {
if (node.is_chat_node) return 1; // companion
if (node.is_repeater) return 2; // repeater
if (node.is_room_server) return 3; // room
if (node.isChatNode) return 1; // companion
if (node.isRepeater) return 2; // repeater
if (node.isRoomServer) return 3; // room
return 4; // sensor (default for standard nodes)
}
@@ -48,9 +49,9 @@ export default function MeshcoreNodePage() {
enabled: !!publicKey
});
// Extract error information from TanStack Query error
const error = queryError?.error || null;
const errorCode = queryError?.code || null;
// Extract error information from the ConnectError
const error = queryError ? (queryError.rawMessage || queryError.message) : null;
const errorCode = nodeErrorCode(queryError);
if (loading) {
@@ -183,7 +184,7 @@ export default function MeshcoreNodePage() {
);
}
if (!nodeData) {
if (!nodeData || !nodeData.node || !nodeData.mqtt) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
<div className="text-center">
@@ -196,7 +197,7 @@ export default function MeshcoreNodePage() {
const { node, recentAdverts, locationHistory, mqtt, region } = nodeData;
return (
<RegionProvider region={region}>
<RegionProvider region={region ?? null}>
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
@@ -205,15 +206,15 @@ export default function MeshcoreNodePage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{node.has_name ? getNameIconLabel(node.node_name) : "Unknown Node"}
{node.hasName ? getNameIconLabel(node.nodeName) : "Unknown Node"}
</h1>
{node.has_name && (
{node.hasName && (
<p className="text-lg text-gray-700 dark:text-gray-300 mb-2">
{node.node_name}
{node.nodeName}
</p>
)}
<p className="text-gray-600 dark:text-gray-300 font-mono text-sm">
{formatPublicKey(node.public_key)}
{formatPublicKey(node.publicKey)}
</p>
{region && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -221,22 +222,22 @@ export default function MeshcoreNodePage() {
</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{node.is_repeater && (
{node.isRepeater && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Repeater
</span>
) || null}
{node.is_chat_node && (
{node.isChatNode && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Companion
</span>
) || null}
{node.is_room_server && (
{node.isRoomServer && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
Room Server
</span>
) || null}
{!node.is_repeater && !node.is_chat_node && !node.is_room_server && (
{!node.isRepeater && !node.isChatNode && !node.isRoomServer && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Unknown
</span>
@@ -245,8 +246,8 @@ export default function MeshcoreNodePage() {
</div>
<div className="text-right">
<ContactQRCode
name={node.has_name ? node.node_name : "Unknown Node"}
publicKey={node.public_key}
name={node.hasName ? node.nodeName : "Unknown Node"}
publicKey={node.publicKey}
type={getNodeType(node)}
size={150}
/>
@@ -265,7 +266,7 @@ export default function MeshcoreNodePage() {
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Public Key</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{node.public_key}
{node.publicKey}
</dd>
</div>
<div>
@@ -273,10 +274,10 @@ export default function MeshcoreNodePage() {
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div className="space-y-1">
<div>
{moment.utc(node.first_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
{moment.utc(node.firstSeen).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div className="text-gray-500 dark:text-gray-400">
{moment.utc(node.first_seen).local().fromNow()}
{moment.utc(node.firstSeen).local().fromNow()}
</div>
</div>
</dd>
@@ -286,10 +287,10 @@ export default function MeshcoreNodePage() {
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div className="space-y-1">
<div>
{moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
{moment.utc(node.lastSeen).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div className="text-gray-500 dark:text-gray-400">
{moment.utc(node.last_seen).local().fromNow()}
{moment.utc(node.lastSeen).local().fromNow()}
</div>
</div>
</dd>
@@ -297,7 +298,7 @@ export default function MeshcoreNodePage() {
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current Location</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{node.has_location && node.latitude && node.longitude ? (
{node.hasLocation && node.latitude && node.longitude ? (
<span>
{node.latitude.toFixed(6)}, {node.longitude.toFixed(6)}
</span>
@@ -311,11 +312,11 @@ export default function MeshcoreNodePage() {
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div className="flex items-center space-x-2 mb-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
mqtt.is_uplinked
mqtt.isUplinked
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{mqtt.is_uplinked ? 'Connected' : 'Not Connected'}
{mqtt.isUplinked ? 'Connected' : 'Not Connected'}
</span>
</div>
@@ -331,10 +332,10 @@ export default function MeshcoreNodePage() {
</div>
<div className="text-right">
<div className="text-gray-900 dark:text-gray-100">
{moment.utc(topic.last_packet_time).format('MM-DD HH:mm')}
{moment.utc(topic.lastPacketTime).format('MM-DD HH:mm')}
</div>
<div className="text-gray-500 dark:text-gray-400">
{moment.utc(topic.last_packet_time).local().fromNow()}
{moment.utc(topic.lastPacketTime).local().fromNow()}
</div>
</div>
</div>
@@ -362,7 +363,7 @@ export default function MeshcoreNodePage() {
</div>
) : (
recentAdverts.map((advert) => (
<AdvertDetails key={advert.group_id} advert={advert} initiatingNodeKey={node.public_key} />
<AdvertDetails key={advert.packetHash} advert={advert} initiatingNodeKey={node.publicKey} />
))
)}
</div>
@@ -387,7 +388,7 @@ export default function MeshcoreNodePage() {
{locationHistory.map((location, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{moment.utc(location.mesh_timestamp).format('MM-DD HH:mm:ss')}
{moment.utc(location.meshTimestamp).format('MM-DD HH:mm:ss')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{location.latitude.toFixed(6)}
@@ -434,23 +435,23 @@ export default function MeshcoreNodePage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{neighbors.map((neighbor) => (
<div key={neighbor.public_key} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors">
<div key={neighbor.publicKey} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{neighbor.has_name ? getNameIconLabel(neighbor.node_name) : "Unknown Node"}
{neighbor.hasName ? getNameIconLabel(neighbor.nodeName) : "Unknown Node"}
</h3>
{neighbor.has_name && (
{neighbor.hasName && (
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">
{neighbor.node_name}
{neighbor.nodeName}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
{formatPublicKey(neighbor.public_key)}
{formatPublicKey(neighbor.publicKey)}
</p>
</div>
<a
href={`/meshcore/node/${neighbor.public_key}`}
href={`/meshcore/node/${neighbor.publicKey}`}
className="ml-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-xs font-medium"
>
View
@@ -458,17 +459,17 @@ export default function MeshcoreNodePage() {
</div>
<div className="flex flex-wrap gap-1 mb-2">
{neighbor.is_repeater && (
{neighbor.isRepeater && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Repeater
</span>
) || null}
{neighbor.is_chat_node && (
{neighbor.isChatNode && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Companion
</span>
) || null}
{neighbor.is_room_server && (
{neighbor.isRoomServer && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
Room
</span>
@@ -476,7 +477,7 @@ export default function MeshcoreNodePage() {
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
{neighbor.has_location && neighbor.latitude && neighbor.longitude && (
{neighbor.hasLocation && neighbor.latitude && neighbor.longitude && (
<div>
Location: {neighbor.latitude.toFixed(4)}, {neighbor.longitude.toFixed(4)}
</div>
+9 -9
View File
@@ -49,7 +49,7 @@ export default function StatsPage() {
repeaterPrefixesQuery.error;
// Extract data with fallbacks
const totalNodes = totalNodesQuery.data?.total_nodes ?? null;
const totalNodes = totalNodesQuery.data?.totalNodes ?? null;
const nodesOverTime = nodesOverTimeQuery.data?.data ?? [];
const popularChannels = popularChannelsQuery.data?.data ?? [];
const repeaterPrefixes = repeaterPrefixesQuery.data?.data ?? [];
@@ -131,11 +131,11 @@ export default function StatsPage() {
{nodesOverTime.map((row, i) => (
<tr key={i} className="border-t">
<td className="border px-3 py-2 text-center min-w-[120px]">{row.day}</td>
<td className="border px-3 py-2 text-center">{row.cumulative_unique_nodes}</td>
<td className="border px-3 py-2 text-center">{row.nodes_with_location}</td>
<td className="border px-3 py-2 text-center">{row.nodes_without_location}</td>
<td className="border px-3 py-2 text-center">{row.cumulativeUniqueNodes}</td>
<td className="border px-3 py-2 text-center">{row.nodesWithLocation}</td>
<td className="border px-3 py-2 text-center">{row.nodesWithoutLocation}</td>
<td className="border px-3 py-2 text-center">{row.repeaters}</td>
<td className="border px-3 py-2 text-center">{row.room_servers}</td>
<td className="border px-3 py-2 text-center">{row.roomServers}</td>
</tr>
))}
</tbody>
@@ -158,8 +158,8 @@ export default function StatsPage() {
<tbody>
{popularChannels.map((row, i) => (
<tr key={i}>
<td className="border px-2 py-1">{row.channel_hash}</td>
<td className="border px-2 py-1">{row.message_count}</td>
<td className="border px-2 py-1">{row.channelHash}</td>
<td className="border px-2 py-1">{row.messageCount}</td>
</tr>
))}
</tbody>
@@ -186,9 +186,9 @@ export default function StatsPage() {
<tr key={i}>
<td className="border px-2 py-1 font-mono">{row.prefix}</td>
<td className="border px-2 py-1">
{row.node_names && row.node_names.length > 0 ? (
{row.nodeNames && row.nodeNames.length > 0 ? (
<div className="space-y-1">
{row.node_names.map((name: string, j: number) => (
{row.nodeNames.map((name: string, j: number) => (
<div key={j} className="text-xs">
{name || 'Unnamed Node'}
</div>
+23 -35
View File
@@ -4,30 +4,18 @@ import { useState } from "react";
import moment from "moment";
import PathVisualization from "./PathVisualization";
import { PathData } from "@/lib/pathUtils";
import type { Advert } from "@/gen/meshexplorer/v1/node_pb";
interface AdvertDetailsProps {
advert: {
group_id: number;
origin_path_pubkey_tuples: Array<[string, string, string]>;
advert_count: number;
earliest_timestamp: string;
latest_timestamp: string;
latitude: number | null;
longitude: number | null;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_location: number;
packet_hash: string;
};
advert: Advert;
initiatingNodeKey?: string;
}
export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetailsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const timeRange = advert.earliest_timestamp !== advert.latest_timestamp
? `to ${moment.utc(advert.latest_timestamp).format('HH:mm:ss')}` : '';
const timeRange = advert.earliestTimestamp !== advert.latestTimestamp
? `to ${moment.utc(advert.latestTimestamp).format('HH:mm:ss')}` : '';
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
@@ -39,28 +27,28 @@ export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetai
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{moment.utc(advert.earliest_timestamp).format('MM-DD HH:mm:ss')}
{moment.utc(advert.earliestTimestamp).format('MM-DD HH:mm:ss')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{timeRange}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Heard {advert.advert_count} time{advert.advert_count !== 1 ? 's' : ''}
Heard {advert.advertCount} time{advert.advertCount !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
{advert.is_repeater && (
{advert.isRepeater && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
R
</span>
) || null}
{advert.is_chat_node && (
{advert.isChatNode && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
C
</span>
) || null}
{advert.is_room_server && (
{advert.isRoomServer && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
S
</span>
@@ -92,19 +80,19 @@ export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetai
{/* Path details */}
<div>
<PathVisualization
paths={advert.origin_path_pubkey_tuples.map(([origin, path, origin_pubkey], index) => ({
origin: origin || origin_pubkey.substring(0, 8), // Use origin name if available, fallback to pubkey
pubkey: origin_pubkey,
path: path
paths={advert.originPathPubkeyTuples.map((t) => ({
origin: t.origin || t.originPubkey.substring(0, 8), // Use origin name if available, fallback to pubkey
pubkey: t.originPubkey,
path: t.path
}))}
className="text-sm"
initiatingNodeKey={initiatingNodeKey}
packetHash={advert.packet_hash}
packetHash={advert.packetHash}
/>
</div>
{/* Location details */}
{advert.has_location && advert.latitude && advert.longitude && (
{advert.hasLocation && advert.latitude && advert.longitude && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
Location
@@ -122,14 +110,14 @@ export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetai
</h4>
<div className="text-sm text-gray-600 dark:text-gray-300 space-y-1">
<div>
<span className="font-medium">Earliest:</span> {moment.utc(advert.earliest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
<span className="font-medium">Earliest:</span> {moment.utc(advert.earliestTimestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div>
<span className="font-medium">Latest:</span> {moment.utc(advert.latest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
<span className="font-medium">Latest:</span> {moment.utc(advert.latestTimestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
{advert.earliest_timestamp !== advert.latest_timestamp && (
{advert.earliestTimestamp !== advert.latestTimestamp && (
<div>
<span className="font-medium">Duration:</span> {moment.utc(advert.latest_timestamp).diff(moment.utc(advert.earliest_timestamp), 'seconds')} seconds
<span className="font-medium">Duration:</span> {moment.utc(advert.latestTimestamp).diff(moment.utc(advert.earliestTimestamp), 'seconds')} seconds
</div>
)}
</div>
@@ -141,22 +129,22 @@ export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetai
Node Capabilities
</h4>
<div className="flex flex-wrap gap-2">
{advert.is_repeater && (
{advert.isRepeater && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Repeater
</span>
) || null}
{advert.is_chat_node && (
{advert.isChatNode && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Companion
</span>
) || null}
{advert.is_room_server && (
{advert.isRoomServer && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
Room Server
</span>
) || null}
{!advert.is_repeater && !advert.is_chat_node && !advert.is_room_server && (
{!advert.isRepeater && !advert.isChatNode && !advert.isRoomServer && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Unknown
</span>
+1 -1
View File
@@ -197,7 +197,7 @@ export default function ChatBox({
{/* Messages */}
{(startExpanded ? messages : messages.toReversed()).map((msg, i) => (
<ChatMessageItem
key={`${msg.message_id}-${msg.origin_path_info?.length || 0}`}
key={`${msg.messageId}-${msg.originPathInfo?.length || 0}`}
msg={msg}
showErrorRow={selectedKey.isAllMessages}
/>
+24 -36
View File
@@ -6,19 +6,7 @@ import PathVisualization from "./PathVisualization";
import { PathData } from "@/lib/pathUtils";
import NodeLinkWithHover from "./NodeLinkWithHover";
import { findNodeMentions } from "@/lib/node-utils";
export interface ChatMessage {
message_id: string;
ingest_timestamp: string;
origins: string[];
mesh_timestamp: string;
path_len: number;
channel_hash: string;
mac: string;
encrypted_message: string;
message_count: number;
origin_path_info: Array<[string, string, string, string, string]>; // Array of [origin, origin_pubkey, path, broker, topic] tuples
}
import type { ChatMessage } from "@/gen/meshexplorer/v1/chat_pb";
function formatHex(hex: string): string {
@@ -113,9 +101,9 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
], [config?.meshcoreKeys]);
const { data: decryptionResult, isLoading } = useMessageDecryption({
encrypted_message: msg.encrypted_message,
encrypted_message: msg.encryptedMessage,
mac: msg.mac,
channel_hash: msg.channel_hash,
channel_hash: msg.channelHash,
knownKeys,
parse: true,
});
@@ -123,17 +111,17 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
const parsed = decryptionResult?.decrypted || null;
const error = decryptionResult?.error || null;
const originPathInfo = useMemo(() =>
msg.origin_path_info && msg.origin_path_info.length > 0 ? msg.origin_path_info : [],
[msg.origin_path_info]
const originPathInfo = useMemo(() =>
msg.originPathInfo && msg.originPathInfo.length > 0 ? msg.originPathInfo : [],
[msg.originPathInfo]
);
// Convert to PathData format for the new component
const pathData: PathData[] = useMemo(() =>
originPathInfo.map(([origin, origin_pubkey, path, broker, topic]) => ({
origin,
pubkey: origin_pubkey,
path
const pathData: PathData[] = useMemo(() =>
originPathInfo.map((o) => ({
origin: o.origin,
pubkey: o.originPubkey,
path: o.path
})),
[originPathInfo]
);
@@ -145,7 +133,7 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
<span className="text-xs text-gray-500 ml-2">channel: {msg.channelHash}</span>
</div>
<div className="break-words whitespace-pre-wrap">
{parsed.sender ? (
@@ -163,7 +151,7 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
paths={pathData}
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
className="text-xs"
packetHash={msg.message_id}
packetHash={msg.messageId}
/>
</div>
);
@@ -174,8 +162,8 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
return (
<div className="border-b border-red-200 dark:border-red-800 pb-2 mb-2 bg-red-50 dark:bg-red-900/30">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(msg.ingest_timestamp)}
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
{formatLocalTime(msg.ingestTimestamp)}
<span className="text-xs text-gray-500 ml-2">channel: {msg.channelHash}</span>
</div>
<div className="text-xs text-red-600 dark:text-red-300">
{error}
@@ -184,7 +172,7 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
paths={pathData}
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
className="text-xs"
packetHash={msg.message_id}
packetHash={msg.messageId}
/>
</div>
);
@@ -197,15 +185,15 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
return (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(msg.ingest_timestamp)}
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
{formatLocalTime(msg.ingestTimestamp)}
<span className="text-xs text-gray-500 ml-2">channel: {msg.channelHash}</span>
</div>
<div className="w-full h-5 bg-gray-200 dark:bg-neutral-800 rounded animate-pulse my-2" />
<PathVisualization
paths={pathData}
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
className="text-xs"
packetHash={msg.message_id}
packetHash={msg.messageId}
/>
</div>
);
@@ -217,12 +205,12 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
export default React.memo(ChatMessageItem, (prevProps, nextProps) => {
// Only re-render if these key properties change
return (
prevProps.msg.message_id === nextProps.msg.message_id &&
prevProps.msg.ingest_timestamp === nextProps.msg.ingest_timestamp &&
prevProps.msg.encrypted_message === nextProps.msg.encrypted_message &&
prevProps.msg.messageId === nextProps.msg.messageId &&
prevProps.msg.ingestTimestamp === nextProps.msg.ingestTimestamp &&
prevProps.msg.encryptedMessage === nextProps.msg.encryptedMessage &&
prevProps.msg.mac === nextProps.msg.mac &&
prevProps.msg.channel_hash === nextProps.msg.channel_hash &&
prevProps.msg.origin_path_info?.length === nextProps.msg.origin_path_info?.length &&
prevProps.msg.channelHash === nextProps.msg.channelHash &&
prevProps.msg.originPathInfo?.length === nextProps.msg.originPathInfo?.length &&
prevProps.showErrorRow === nextProps.showErrorRow
);
});
+12 -13
View File
@@ -2,7 +2,7 @@ import React from 'react';
import moment from "moment";
import { formatPublicKey } from '@/lib/meshcore';
import { getNameIconLabel } from '@/lib/meshcore-map-nodeutils';
import { NodePosition } from '@/types/map';
import type { NodePosition } from '@/gen/meshexplorer/v1/map_pb';
interface NodeMarkerProps {
node: NodePosition;
@@ -35,9 +35,9 @@ export function NodeMarker({ node, showNodeNames = true, isSelected = false, isL
return (
<div className="custom-node-marker-container">
{showNodeNames && node.short_name && (
{showNodeNames && node.shortName && (
<div className="custom-node-label">
{getNameIconLabel(node.name || node.short_name)}
{getNameIconLabel(node.name || node.shortName)}
</div>
)}
<div className={getMarkerClass()}></div>
@@ -101,32 +101,31 @@ export function ClusterMarker({ children }: ClusterMarkerProps) {
export function PopupContent({ node, target = '_self' }: PopupContentProps) {
return (
<div>
<div><b>ID:</b> {formatPublicKey(node.node_id)}</div>
<div><b>ID:</b> {formatPublicKey(node.nodeId)}</div>
<div><b>Full Name:</b> {node.name ?? "-"}</div>
<div><b>Short Name:</b> {node.short_name ? getNameIconLabel(node.name || node.short_name) : "-"}</div>
<div><b>Short Name:</b> {node.shortName ? getNameIconLabel(node.name || node.shortName) : "-"}</div>
<div><b>Type:</b> {node.type ?? "-"}</div>
<div><b>Lat:</b> {node.latitude}</div>
<div><b>Lng:</b> {node.longitude}</div>
<div><b>Alt:</b> {node.altitude !== undefined ? node.altitude : "-"}</div>
{node.last_seen ? (
{node.lastSeen ? (
<div>
<b>Last seen:</b> {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} <span style={{color: '#888'}}>(UTC)</span><br/>
<span style={{color: '#888'}}>{moment.utc(node.last_seen).local().fromNow()}</span>
<b>Last seen:</b> {moment.utc(node.lastSeen).format('YYYY-MM-DD HH:mm:ss')} <span style={{color: '#888'}}>(UTC)</span><br/>
<span style={{color: '#888'}}>{moment.utc(node.lastSeen).local().fromNow()}</span>
</div>
) : (
<div><b>Last seen:</b> -</div>
)}
{node.first_seen ? (
{node.firstSeen ? (
<div>
<b>First seen:</b> {moment.utc(node.first_seen).format('YYYY-MM-DD HH:mm:ss')} <span style={{color: '#888'}}>(UTC)</span><br/>
<span style={{color: '#888'}}>{moment.utc(node.first_seen).local().fromNow()}</span>
<b>First seen:</b> {moment.utc(node.firstSeen).format('YYYY-MM-DD HH:mm:ss')} <span style={{color: '#888'}}>(UTC)</span><br/>
<span style={{color: '#888'}}>{moment.utc(node.firstSeen).local().fromNow()}</span>
</div>
) : (
<div><b>First seen:</b> -</div>
)}
<div style={{marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #e5e7eb'}}>
<a
href={`/meshcore/node/${node.node_id}`}
href={`/meshcore/node/${node.nodeId}`}
target={target}
style={{
display: 'inline-block',
+33 -62
View File
@@ -15,9 +15,10 @@ import { NodeMarker, ClusterMarker, PopupContent } from "./MapIcons";
import { renderToString } from "react-dom/server";
import { Code, ConnectError } from "@connectrpc/connect";
import { mapClient } from "@/lib/connect/client";
import { NodePosition } from "@/types/map";
import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors";
import { type AllNeighborsConnection } from "@/hooks/useAllNeighbors";
import type { NodePosition } from "@/gen/meshexplorer/v1/map_pb";
import type { Neighbor } from "@/gen/meshexplorer/v1/node_pb";
import type { NeighborEdge } from "@/gen/meshexplorer/v1/common_pb";
import { useNeighbors } from "@/hooks/useNeighbors";
import { useQueryParams } from "@/hooks/useQueryParams";
import { useMapPosition } from "@/hooks/useMapPosition";
@@ -67,7 +68,7 @@ const IndividualMarker = React.memo(function IndividualMarker({
useEffect(() => {
if (!map) return;
const isSelected = selectedNodeId === node.node_id;
const isSelected = selectedNodeId === node.nodeId;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
@@ -89,7 +90,7 @@ const IndividualMarker = React.memo(function IndividualMarker({
// Add hover handler for meshcore nodes
if (node.type === "meshcore") {
marker.on('mouseover', () => {
onNodeClickRef.current(node.node_id);
onNodeClickRef.current(node.nodeId);
});
}
@@ -102,13 +103,13 @@ const IndividualMarker = React.memo(function IndividualMarker({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omitting selectedNodeId, showNodeNames, isLoadingNeighbors to prevent marker recreation
}, [map, node.node_id, node.latitude, node.longitude, node.type, target]);
}, [map, node.nodeId, node.latitude, node.longitude, node.type, target]);
// Update marker when visual properties change (but don't recreate marker)
useEffect(() => {
if (markerRef.current) {
// Update icon and popup content only
const isSelected = selectedNodeId === node.node_id;
const isSelected = selectedNodeId === node.nodeId;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
@@ -185,7 +186,7 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
});
nodes.forEach((node: NodePosition) => {
const isSelected = selectedNodeId === node.node_id;
const isSelected = selectedNodeId === node.nodeId;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
@@ -206,7 +207,7 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
// Add hover handler for meshcore nodes
if (node.type === "meshcore") {
marker.on('mouseover', () => {
onNodeClickRef.current(node.node_id);
onNodeClickRef.current(node.nodeId);
});
}
@@ -232,7 +233,7 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
clusterGroupRef.current.eachLayer((marker: any) => {
const nodeData = marker.options.nodeData;
if (nodeData) {
const isSelected = selectedNodeId === nodeData.node_id;
const isSelected = selectedNodeId === nodeData.nodeId;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
@@ -271,7 +272,7 @@ const ClusteredMarkers = React.memo(function ClusteredMarkers({
<>
{nodes.map((node) => (
<IndividualMarker
key={node.node_id}
key={node.nodeId}
node={node}
showNodeNames={showNodeNames}
selectedNodeId={selectedNodeId}
@@ -312,15 +313,15 @@ function NeighborLines({
if (!selectedNodeId || neighbors.length === 0) return null;
// Find the selected node's position
const selectedNode = nodes.find(node => node.node_id === selectedNodeId);
const selectedNode = nodes.find(node => node.nodeId === selectedNodeId);
if (!selectedNode) return null;
// Create lines to neighbors that have location data and are visible on the map
const lines = neighbors
.filter(neighbor => neighbor.has_location && neighbor.latitude && neighbor.longitude)
.filter(neighbor => neighbor.hasLocation && neighbor.latitude && neighbor.longitude)
.map(neighbor => {
// Check if the neighbor is also visible on the map
const neighborOnMap = nodes.find(node => node.node_id === neighbor.public_key);
const neighborOnMap = nodes.find(node => node.nodeId === neighbor.publicKey);
const hasIncoming = neighbor.directions?.includes('incoming') || false;
const hasOutgoing = neighbor.directions?.includes('outgoing') || false;
@@ -347,7 +348,7 @@ function NeighborLines({
return (
<Polyline
key={`${selectedNodeId}-${neighbor.public_key}`}
key={`${selectedNodeId}-${neighbor.publicKey}`}
positions={positions}
pathOptions={{
color: lineColor,
@@ -370,7 +371,7 @@ function AllNeighborLines({
minPacketCount = 1,
strokeWidth = 2
}: {
connections: AllNeighborsConnection[];
connections: NeighborEdge[];
nodes: NodePosition[];
useColors?: boolean;
minPacketCount?: number;
@@ -379,19 +380,19 @@ function AllNeighborLines({
if (connections.length === 0) return null;
// Create a set of visible node IDs for quick lookup
const visibleNodeIds = new Set(nodes.map(node => node.node_id));
const visibleNodeIds = new Set(nodes.map(node => node.nodeId));
// Filter connections to only show lines between nodes that are visible on the map
// and meet the minimum packet count threshold
const visibleConnections = connections.filter(connection =>
visibleNodeIds.has(connection.source_node) &&
visibleNodeIds.has(connection.target_node) &&
connection.packet_count >= minPacketCount
visibleNodeIds.has(connection.sourceNode) &&
visibleNodeIds.has(connection.targetNode) &&
connection.packetCount >= minPacketCount
);
// Calculate logarithmic thresholds based on packet counts for path connections
const pathConnections = visibleConnections.filter(conn => conn.connection_type === 'path');
const packetCounts = pathConnections.map(conn => conn.packet_count).sort((a, b) => a - b);
const pathConnections = visibleConnections.filter(conn => conn.connectionType === 'path');
const packetCounts = pathConnections.map(conn => conn.packetCount).sort((a, b) => a - b);
const getLogThresholds = (counts: number[]) => {
if (counts.length === 0) return { min: 1, t1: 1, t2: 1, t3: 1, t4: 1, max: 1 };
@@ -429,8 +430,8 @@ function AllNeighborLines({
<>
{visibleConnections.map((connection) => {
const positions: [number, number][] = [
[connection.source_latitude, connection.source_longitude],
[connection.target_latitude, connection.target_longitude]
[connection.sourceLatitude, connection.sourceLongitude],
[connection.targetLatitude, connection.targetLongitude]
];
// Different colors based on connection type and logarithmic packet count
@@ -453,14 +454,14 @@ function AllNeighborLines({
return '#6b7280'; // Gray for minimum traffic
};
const lineColor = getConnectionColor(connection.connection_type, connection.packet_count);
const lineColor = getConnectionColor(connection.connectionType, connection.packetCount);
// Use strokeWidth setting for line weight
const lineWeight = connection.connection_type === 'direct' ? strokeWidth : Math.max(1, strokeWidth - 1);
const lineWeight = connection.connectionType === 'direct' ? strokeWidth : Math.max(1, strokeWidth - 1);
return (
<Polyline
key={`${connection.source_node}-${connection.target_node}-${connection.connection_type}`}
key={`${connection.sourceNode}-${connection.targetNode}-${connection.connectionType}`}
positions={positions}
pathOptions={{
color: lineColor,
@@ -518,7 +519,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
// Neighbor-related state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [showAllNeighbors, setShowAllNeighbors] = useState<boolean>(false);
const [allNeighborConnections, setAllNeighborConnections] = useState<AllNeighborsConnection[]>([]);
const [allNeighborConnections, setAllNeighborConnections] = useState<NeighborEdge[]>([]);
const [allNeighborsLoading, setAllNeighborsLoading] = useState<boolean>(false);
// Update showAllNeighbors when mapLayerSettings changes
@@ -598,39 +599,9 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
mapClient
.getMap(request, { signal: controller.signal })
.then((res) => {
setNodePositions(
res.nodes.map((n) => ({
node_id: n.nodeId,
latitude: n.latitude,
longitude: n.longitude,
last_seen: n.lastSeen,
first_seen: n.firstSeen,
type: n.type,
short_name: n.shortName,
name: n.name ?? null,
})),
);
setNodePositions(res.nodes);
setLastResultCount(res.nodes.length);
if (includeNeighbors) {
setAllNeighborConnections(
res.neighbors.map((e) => ({
source_node: e.sourceNode,
target_node: e.targetNode,
connection_type: e.connectionType,
packet_count: e.packetCount,
source_name: e.sourceName,
source_latitude: e.sourceLatitude,
source_longitude: e.sourceLongitude,
source_has_location: e.sourceHasLocation,
target_name: e.targetName,
target_latitude: e.targetLatitude,
target_longitude: e.targetLongitude,
target_has_location: e.targetHasLocation,
})),
);
} else {
setAllNeighborConnections([]);
}
setAllNeighborConnections(includeNeighbors ? res.neighbors : []);
if (fetchController.current === controller) {
setLoading(false);
@@ -856,8 +827,8 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
{/* Traffic Legend */}
{showAllNeighbors && mapLayerSettings.useColors && allNeighborConnections.length > 0 && (() => {
// Calculate logarithmic thresholds for legend display
const pathConnections = allNeighborConnections.filter(conn => conn.connection_type === 'path');
const packetCounts = pathConnections.map(conn => conn.packet_count).sort((a, b) => a - b);
const pathConnections = allNeighborConnections.filter(conn => conn.connectionType === 'path');
const packetCounts = pathConnections.map(conn => conn.packetCount).sort((a, b) => a - b);
const legendThresholds = packetCounts.length > 0 ? (() => {
const min = Math.max(1, packetCounts[0]);
const max = packetCounts[packetCounts.length - 1];
@@ -1,25 +1,13 @@
"use client";
import dynamic from "next/dynamic";
type NodePosition = {
from_node_id: string;
latitude: number;
longitude: number;
altitude?: number;
last_seen?: string;
};
interface MapWithChatProps {
nodePositions?: NodePosition[];
}
const MapView = dynamic(
() => import("./MapView"),
{ ssr: false }
);
const ChatBox = dynamic(() => import("./ChatBox"), { ssr: false });
export default function MapWithChat({ nodePositions }: MapWithChatProps) {
export default function MapWithChat() {
return (
<div
className="flex flex-col w-screen overflow-hidden"
+10 -24
View File
@@ -4,44 +4,30 @@ import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon } from '@hero
import Link from 'next/link';
import moment from 'moment';
import { formatPublicKey } from '@/lib/meshcore';
import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch';
export interface NodeCardData {
public_key: string;
node_name: string | null;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
last_seen: string;
topic?: string;
broker?: string;
}
import type { SearchResult } from '@/gen/meshexplorer/v1/node_pb';
interface NodeCardProps {
node: NodeCardData | MeshcoreSearchResult;
node: SearchResult;
className?: string;
showTopicInfo?: boolean;
}
export default function NodeCard({ node, className = "", showTopicInfo = true }: NodeCardProps) {
const hasLocation = node.has_location === 1;
const isRepeater = node.is_repeater === 1;
const isChatNode = node.is_chat_node === 1;
const isRoomServer = node.is_room_server === 1;
const hasLocation = node.hasLocation === 1;
const isRepeater = node.isRepeater === 1;
const isChatNode = node.isChatNode === 1;
const isRoomServer = node.isRoomServer === 1;
return (
<Link
href={`/meshcore/node/${node.public_key}`}
href={`/meshcore/node/${node.publicKey}`}
className={`block bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md dark:hover:shadow-lg transition-shadow ${className}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
{node.node_name || 'Unnamed Node'}
{node.nodeName || 'Unnamed Node'}
</h4>
<div className="flex items-center gap-1">
{isRepeater && (
@@ -76,9 +62,9 @@ export default function NodeCard({ node, className = "", showTopicInfo = true }:
)}
<div className="flex items-center gap-4">
<span>Last seen: {moment.utc(node.last_seen).local().fromNow()}</span>
<span>Last seen: {moment.utc(node.lastSeen).local().fromNow()}</span>
<span className="text-xs font-mono text-gray-500 dark:text-gray-500">
{formatPublicKey(node.public_key)}
{formatPublicKey(node.publicKey)}
</span>
</div>
@@ -51,7 +51,7 @@ export default function NodeLinkWithHover({
if (isSearchLoading) return "#";
// If exactly one result found, link directly to node
if (foundNode) return `/meshcore/node/${foundNode.public_key}`;
if (foundNode) return `/meshcore/node/${foundNode.publicKey}`;
// If no results or multiple results, link to search page
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
@@ -83,7 +83,7 @@ export default function NodeLinkWithHover({
// Calculate navigation URL directly here since linkHref might still be "#"
const navigationUrl = foundNode
? `/meshcore/node/${foundNode.public_key}`
? `/meshcore/node/${foundNode.publicKey}`
: (() => {
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
const params = [];
@@ -8,7 +8,6 @@ import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24
import { ExternalLink } from "lucide-react";
import NodeLinkWithHover from "./NodeLinkWithHover";
import { useMeshcoreSearches } from "@/hooks/useMeshcoreSearch";
import type { MeshcoreSearchResult } from "@/hooks/useMeshcoreSearch";
import { useConfigWithRegion } from "@/hooks/useConfigWithRegion";
import {
PathData,
@@ -91,10 +90,10 @@ export default function PathVisualization({
const searchResult = searchResults[index];
if (searchResult?.data?.results) {
const matchingNodes = searchResult.data.results
.filter(result => result.public_key.toLowerCase().startsWith(prefix.toLowerCase()) && result.node_name)
.filter(result => result.publicKey.toLowerCase().startsWith(prefix.toLowerCase()) && result.nodeName)
.map(result => ({
name: result.node_name,
publicKey: result.public_key
name: result.nodeName,
publicKey: result.publicKey
}))
.filter(node => node.name.length > 0);
@@ -1,11 +1,11 @@
"use client";
import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch';
import type { SearchResult } from '@/gen/meshexplorer/v1/node_pb';
import { WifiIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import NodeCard from '@/components/NodeCard';
interface SearchResultsProps {
results: MeshcoreSearchResult[];
results: SearchResult[];
isLoading: boolean;
error: Error | null;
query: string;
@@ -84,7 +84,7 @@ export default function SearchResults({ results, isLoading, error, query, total
<div className="space-y-3">
{results.map((node) => (
<NodeCard key={node.public_key} node={node} />
<NodeCard key={node.publicKey} node={node} />
))}
</div>
</div>
-72
View File
@@ -1,72 +0,0 @@
import { useQuery } from '@connectrpc/connect-query';
import { NeighborsService } from '@/gen/meshexplorer/v1/neighbors_pb';
export interface AllNeighborsConnection {
source_node: string;
target_node: string;
connection_type: string;
packet_count: number;
source_name: string;
source_latitude: number;
source_longitude: number;
source_has_location: number;
target_name: string;
target_latitude: number;
target_longitude: number;
target_has_location: number;
}
interface UseAllNeighborsParams {
minLat?: number | null;
maxLat?: number | null;
minLng?: number | null;
maxLng?: number | null;
nodeTypes?: string[];
lastSeen?: number | null;
region?: string;
enabled?: boolean;
}
export function useAllNeighbors({
minLat,
maxLat,
minLng,
maxLng,
nodeTypes,
lastSeen,
region,
enabled = true,
}: UseAllNeighborsParams) {
return useQuery(
NeighborsService.method.getAllNeighbors,
{
minLat: minLat ?? undefined,
maxLat: maxLat ?? undefined,
minLng: minLng ?? undefined,
maxLng: maxLng ?? undefined,
nodeTypes: nodeTypes ?? [],
lastSeen: lastSeen ?? undefined,
region,
},
{
select: (res): AllNeighborsConnection[] =>
res.neighbors.map((n) => ({
source_node: n.sourceNode,
target_node: n.targetNode,
connection_type: n.connectionType,
packet_count: n.packetCount,
source_name: n.sourceName,
source_latitude: n.sourceLatitude,
source_longitude: n.sourceLongitude,
source_has_location: n.sourceHasLocation,
target_name: n.targetName,
target_latitude: n.targetLatitude,
target_longitude: n.targetLongitude,
target_has_location: n.targetHasLocation,
})),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
},
);
}
+9 -34
View File
@@ -4,8 +4,7 @@ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { Code, ConnectError } from '@connectrpc/connect';
import { chatClient } from '@/lib/connect/client';
import type { ChatMessage as GenChatMessage } from '@/gen/meshexplorer/v1/chat_pb';
import { ChatMessage } from '@/components/ChatMessageItem';
import type { ChatMessage } from '@/gen/meshexplorer/v1/chat_pb';
interface ChatMessagesParams {
channelId?: string;
@@ -22,38 +21,13 @@ interface ChatMessagesPage {
const PAGE_SIZE = 20;
// Maps the generated (camelCase) ChatMessage to the snake_case shape the chat
// components consume. `origins`/`path_len` were never populated by the REST API
// and are unused by the renderer, so they're intentionally omitted.
function toChatMessage(m: GenChatMessage): ChatMessage {
return {
message_id: m.messageId,
ingest_timestamp: m.ingestTimestamp,
mesh_timestamp: m.meshTimestamp,
channel_hash: m.channelHash,
mac: m.mac,
encrypted_message: m.encryptedMessage,
message_count: m.messageCount,
origin_path_info: m.originPathInfo.map(
(o) =>
[o.origin, o.originPubkey, o.path, o.broker, o.topic] as [
string,
string,
string,
string,
string,
],
),
} as ChatMessage;
}
// Inserts a single streamed message into the infinite-query cache, de-duping by
// message_id, keeping newest-first order, and re-paginating into PAGE_SIZE pages.
// messageId, keeping newest-first order, and re-paginating into PAGE_SIZE pages.
function mergeStreamedMessage(oldData: any, newMessage: ChatMessage) {
if (!oldData?.pages?.[0]) return oldData;
const all = oldData.pages.flatMap((p: ChatMessagesPage) => p.messages) as ChatMessage[];
const existingIndex = all.findIndex((m) => m.message_id === newMessage.message_id);
const existingIndex = all.findIndex((m) => m.messageId === newMessage.messageId);
let merged: ChatMessage[];
if (existingIndex !== -1) {
@@ -64,7 +38,7 @@ function mergeStreamedMessage(oldData: any, newMessage: ChatMessage) {
}
merged.sort(
(a, b) => new Date(b.ingest_timestamp).getTime() - new Date(a.ingest_timestamp).getTime(),
(a, b) => new Date(b.ingestTimestamp).getTime() - new Date(a.ingestTimestamp).getTime(),
);
const pages = [];
@@ -111,13 +85,13 @@ export function useChatMessages({
{ signal },
);
const messages = res.messages.map(toChatMessage);
const messages = res.messages;
return {
messages,
hasMore: messages.length === PAGE_SIZE,
oldestTimestamp:
messages.length > 0 ? messages[messages.length - 1].ingest_timestamp : undefined,
messages.length > 0 ? messages[messages.length - 1].ingestTimestamp : undefined,
};
},
getNextPageParam: (lastPage) => {
@@ -152,9 +126,10 @@ export function useChatMessages({
{ signal: controller.signal },
);
for await (const genMsg of stream) {
for await (const resp of stream) {
if (cancelled) break;
const msg = toChatMessage(genMsg);
if (!resp.message) continue;
const msg = resp.message;
queryClient.setQueryData(baseQueryKey, (oldData: any) =>
mergeStreamedMessage(oldData, msg),
);
+6 -41
View File
@@ -4,43 +4,8 @@ import { nodeClient } from '@/lib/connect/client';
import type { SearchResult } from '@/gen/meshexplorer/v1/node_pb';
import { useMemo } from 'react';
export interface MeshcoreSearchResult {
public_key: string;
node_name: string;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_name: number;
first_heard: string;
last_seen: string;
broker: string;
topic: string;
}
// Maps a generated (camelCase) SearchResult to the snake_case shape consumers use.
function toSearchResult(r: SearchResult): MeshcoreSearchResult {
return {
public_key: r.publicKey,
node_name: r.nodeName,
latitude: r.latitude ?? null,
longitude: r.longitude ?? null,
has_location: r.hasLocation,
is_repeater: r.isRepeater,
is_chat_node: r.isChatNode,
is_room_server: r.isRoomServer,
has_name: r.hasName,
first_heard: r.firstHeard,
last_seen: r.lastSeen,
broker: r.broker,
topic: r.topic,
};
}
export interface MeshcoreSearchResponse {
results: MeshcoreSearchResult[];
results: SearchResult[];
total: number;
}
@@ -88,12 +53,12 @@ const searchBatcher = create({
// Return results with batch context for resolver (array-of-arrays, one per query)
return {
results: response.results.map((list) => list.results.map(toSearchResult)),
results: response.results.map((list) => list.results),
queries: queries
};
},
resolver: (batchData: {results: MeshcoreSearchResult[][], queries: SearchQuery[]}, query: SearchQuery) => {
resolver: (batchData: {results: SearchResult[][], queries: SearchQuery[]}, query: SearchQuery) => {
const index = batchData.queries.findIndex(q => JSON.stringify(q) === JSON.stringify(query));
return batchData.results[index] || [];
},
@@ -146,7 +111,7 @@ export function useMeshcoreSearch({
signal?.addEventListener('abort', handleAbort);
try {
const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || [];
const queryResults = await searchBatcher.fetch(searchQuery) as SearchResult[] || [];
return {
results: queryResults,
total: queryResults.length
@@ -214,7 +179,7 @@ export function useMeshcoreSearches({ searches }: UseMeshcoreSearchesParams) {
signal?.addEventListener('abort', handleAbort);
try {
const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || [];
const queryResults = await searchBatcher.fetch(searchQuery) as SearchResult[] || [];
return {
results: queryResults,
total: queryResults.length
+2 -26
View File
@@ -1,19 +1,6 @@
import { useQuery } from '@connectrpc/connect-query';
import { NodeService } from '@/gen/meshexplorer/v1/node_pb';
export interface Neighbor {
public_key: string;
node_name: string;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_name: number;
directions: string[];
}
interface UseNeighborsParams {
nodeId: string | null;
lastSeen?: number | null;
@@ -28,19 +15,8 @@ export function useNeighbors({ nodeId, lastSeen, enabled = true }: UseNeighborsP
lastSeen: lastSeen ?? undefined,
},
{
select: (res): Neighbor[] =>
res.neighbors.map((n) => ({
public_key: n.publicKey,
node_name: n.nodeName,
latitude: n.latitude ?? null,
longitude: n.longitude ?? null,
has_location: n.hasLocation,
is_repeater: n.isRepeater,
is_chat_node: n.isChatNode,
is_room_server: n.isRoomServer,
has_name: n.hasName,
directions: n.directions,
})),
// Unwrap the response to the generated Neighbor[] (no field mapping).
select: (res) => res.neighbors,
enabled: enabled && !!nodeId,
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
+30 -165
View File
@@ -1,71 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery } from '@connectrpc/connect-query';
import { Code, ConnectError } from '@connectrpc/connect';
import { nodeClient } from '@/lib/connect/client';
import type { GetNodeResponse } from '@/gen/meshexplorer/v1/node_pb';
export interface NodeInfo {
public_key: string;
node_name: string;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_name: number;
broker: string | null;
topic: string | null;
first_seen: string;
last_seen: string;
}
export interface Advert {
group_id: number;
origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples
advert_count: number;
earliest_timestamp: string;
latest_timestamp: string;
latitude: number | null;
longitude: number | null;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_location: number;
packet_hash: string;
}
export interface LocationHistory {
mesh_timestamp: string;
latitude: number;
longitude: number;
}
export interface MqttTopic {
topic: string;
broker: string;
last_packet_time: string;
is_recent: boolean;
}
export interface MqttInfo {
is_uplinked: boolean;
has_packets: boolean;
topics: MqttTopic[];
}
export interface NodeData {
node: NodeInfo;
recentAdverts: Advert[];
locationHistory: LocationHistory[];
mqtt: MqttInfo;
region: string | null;
}
export interface NodeError {
error: string;
code: string;
publicKey?: string;
}
import { NodeService } from '@/gen/meshexplorer/v1/node_pb';
interface UseNodeDataParams {
publicKey: string | null;
@@ -73,107 +8,37 @@ interface UseNodeDataParams {
enabled?: boolean;
}
// Maps the generated (camelCase) GetNodeResponse to the snake_case NodeData
// shape the page components already consume.
function toNodeData(res: GetNodeResponse): NodeData {
const node = res.node!;
return {
node: {
public_key: node.publicKey,
node_name: node.nodeName,
latitude: node.latitude ?? null,
longitude: node.longitude ?? null,
has_location: node.hasLocation,
is_repeater: node.isRepeater,
is_chat_node: node.isChatNode,
is_room_server: node.isRoomServer,
has_name: node.hasName,
broker: node.broker ?? null,
topic: node.topic ?? null,
first_seen: node.firstSeen,
last_seen: node.lastSeen,
},
recentAdverts: res.recentAdverts.map((a, index) => ({
group_id: index,
origin_path_pubkey_tuples: a.originPathPubkeyTuples.map(
(t) => [t.origin, t.path, t.originPubkey] as [string, string, string],
),
advert_count: a.advertCount,
earliest_timestamp: a.earliestTimestamp,
latest_timestamp: a.latestTimestamp,
latitude: a.latitude ?? null,
longitude: a.longitude ?? null,
is_repeater: a.isRepeater,
is_chat_node: a.isChatNode,
is_room_server: a.isRoomServer,
has_location: a.hasLocation,
packet_hash: a.packetHash,
})),
locationHistory: res.locationHistory.map((l) => ({
mesh_timestamp: l.meshTimestamp,
latitude: l.latitude,
longitude: l.longitude,
})),
mqtt: {
is_uplinked: res.mqtt?.isUplinked ?? false,
has_packets: res.mqtt?.hasPackets ?? false,
topics: (res.mqtt?.topics ?? []).map((t) => ({
topic: t.topic,
broker: t.broker,
last_packet_time: t.lastPacketTime,
is_recent: t.isRecent,
})),
},
region: res.region ?? null,
};
}
// Maps a ConnectError to the NodeError shape (with HTTP-like status) the
// node page uses to pick an error icon/title and drive retry behavior.
function toNodeError(err: unknown): NodeError & { status: number } {
if (err instanceof ConnectError) {
switch (err.code) {
case Code.NotFound:
return { error: err.message, code: 'NODE_NOT_FOUND', status: 404 };
case Code.InvalidArgument:
return { error: err.message, code: 'INVALID_PUBLIC_KEY', status: 400 };
case Code.Unavailable:
return { error: err.message, code: 'DATABASE_ERROR', status: 503 };
default:
return { error: err.message, code: 'INTERNAL_ERROR', status: 500 };
}
// Maps a ConnectError to the string code the node page uses to pick an
// error icon/title/description. (Error-code mapping, not a proto-type mirror.)
export function nodeErrorCode(err: ConnectError | null): string | null {
if (!err) return null;
switch (err.code) {
case Code.NotFound:
return 'NODE_NOT_FOUND';
case Code.InvalidArgument:
return 'INVALID_PUBLIC_KEY';
case Code.Unavailable:
return 'DATABASE_ERROR';
default:
return 'INTERNAL_ERROR';
}
return { error: 'An unexpected error occurred', code: 'UNKNOWN_ERROR', status: 500 };
}
export function useNodeData({ publicKey, limit = 50, enabled = true }: UseNodeDataParams) {
return useQuery<NodeData, NodeError & { status: number }>({
queryKey: ['node-data', publicKey, limit],
queryFn: async (): Promise<NodeData> => {
if (!publicKey) {
throw { error: 'Public key is required', code: 'MISSING_PUBLIC_KEY', status: 400 } as NodeError & {
status: number;
};
}
try {
const res = await nodeClient.getNode({ publicKey, limit });
return toNodeData(res);
} catch (err) {
throw toNodeError(err);
}
return useQuery(
NodeService.method.getNode,
{ publicKey: publicKey ?? '', limit },
{
enabled: enabled && !!publicKey,
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
retry: (failureCount, error) => {
// Don't retry client errors (bad key / not found).
if (error.code === Code.NotFound || error.code === Code.InvalidArgument) {
return false;
}
return failureCount < 1;
},
},
enabled: enabled && !!publicKey,
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
retry: (failureCount, error) => {
// Don't retry for client errors (4xx)
const status = error?.status;
if (status && status >= 400 && status < 500) {
return false;
}
// Retry up to 1 time for server errors
return failureCount < 1;
},
});
);
}
+6 -80
View File
@@ -2,41 +2,6 @@ import React from "react";
import { useQuery } from "@connectrpc/connect-query";
import { StatsService } from "@/gen/meshexplorer/v1/stats_pb";
interface TotalNodesResponse {
total_nodes: number;
}
interface NodesOverTimeRow {
day: string;
cumulative_unique_nodes: number;
nodes_with_location: number;
nodes_without_location: number;
repeaters: number;
room_servers: number;
}
interface NodesOverTimeResponse {
data: NodesOverTimeRow[];
}
interface PopularChannelRow {
channel_hash: string;
message_count: number;
}
interface PopularChannelsResponse {
data: PopularChannelRow[];
}
interface RepeaterPrefixRow {
prefix: string;
node_names: string[];
}
interface RepeaterPrefixesResponse {
data: RepeaterPrefixRow[];
}
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
const GC_TIME = 10 * 60 * 1000; // 10 minutes
@@ -44,12 +9,7 @@ export function useTotalNodes(region?: string) {
return useQuery(
StatsService.method.getTotalNodes,
{ region },
{
select: (res): TotalNodesResponse => ({ total_nodes: res.totalNodes }),
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
},
{ staleTime: STALE_TIME, gcTime: GC_TIME, retry: 2 },
);
}
@@ -57,21 +17,7 @@ export function useNodesOverTime(region?: string) {
return useQuery(
StatsService.method.getNodesOverTime,
{ region },
{
select: (res): NodesOverTimeResponse => ({
data: res.data.map((r) => ({
day: r.day,
cumulative_unique_nodes: r.cumulativeUniqueNodes,
nodes_with_location: r.nodesWithLocation,
nodes_without_location: r.nodesWithoutLocation,
repeaters: r.repeaters,
room_servers: r.roomServers,
})),
}),
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
},
{ staleTime: STALE_TIME, gcTime: GC_TIME, retry: 2 },
);
}
@@ -79,17 +25,7 @@ export function usePopularChannels(region?: string) {
return useQuery(
StatsService.method.getPopularChannels,
{ region },
{
select: (res): PopularChannelsResponse => ({
data: res.data.map((r) => ({
channel_hash: r.channelHash,
message_count: r.messageCount,
})),
}),
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
},
{ staleTime: STALE_TIME, gcTime: GC_TIME, retry: 2 },
);
}
@@ -97,17 +33,7 @@ export function useRepeaterPrefixes(region?: string) {
return useQuery(
StatsService.method.getRepeaterPrefixes,
{ region },
{
select: (res): RepeaterPrefixesResponse => ({
data: res.data.map((r) => ({
prefix: r.prefix,
node_names: r.nodeNames,
})),
}),
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
},
{ staleTime: STALE_TIME, gcTime: GC_TIME, retry: 2 },
);
}
@@ -124,10 +50,10 @@ export function useUnusedPrefixes(region?: string) {
}
// Get used prefixes from the API response
const usedPrefixes = new Set(repeaterPrefixesData.data.map(row => row.prefix));
const usedPrefixes = new Set(repeaterPrefixesData.data.map((row) => row.prefix));
// Find unused prefixes
return allPrefixes.filter(prefix => !usedPrefixes.has(prefix));
return allPrefixes.filter((prefix) => !usedPrefixes.has(prefix));
}, [repeaterPrefixesData?.data]);
return {
+1 -1
View File
@@ -122,7 +122,7 @@ export const chatServiceImpl: ServiceImpl<typeof ChatService> = {
for await (const result of streamer(params)) {
const row = result.row;
const decrypted = req.decrypt ? await decryptRow(row, keys) : null;
yield toChatMessage(row, decrypted);
yield { message: toChatMessage(row, decrypted) };
}
},
};
+14 -12
View File
@@ -43,18 +43,20 @@ export const packetsServiceImpl: ServiceImpl<typeof PacketsService> = {
for await (const result of streamer(params)) {
const row = result.row;
yield {
ingestTimestamp: row.ingest_timestamp,
meshTimestamp: row.mesh_timestamp,
broker: row.broker,
topic: row.topic,
packet: row.packet,
pathLen: num(row.path_len),
path: row.path,
routeType: num(row.route_type),
payloadType: num(row.payload_type),
payloadVersion: num(row.payload_version),
header: num(row.header),
originPubkey: row.origin_pubkey,
packet: {
ingestTimestamp: row.ingest_timestamp,
meshTimestamp: row.mesh_timestamp,
broker: row.broker,
topic: row.topic,
packet: row.packet,
pathLen: num(row.path_len),
path: row.path,
routeType: num(row.route_type),
payloadType: num(row.payload_type),
payloadVersion: num(row.payload_version),
header: num(row.header),
originPubkey: row.origin_pubkey,
},
};
}
},
-11
View File
@@ -1,11 +0,0 @@
export type NodePosition = {
node_id: string;
latitude: number;
longitude: number;
altitude?: number;
last_seen?: string;
first_seen?: string;
type?: string;
short_name?: string;
name?: string | null;
};