Migrate API to ConnectRPC with protovalidate

Put a schema-first ConnectRPC contract in front of the API, served alongside
the existing REST routes (kept live for a gradual cutover).

- Proto: meshexplorer.v1 services (Map, Node, Neighbors, Stats, Chat, Packets)
  in proto/, generated to src/gen via buf + protoc-gen-es. Request messages
  carry buf.validate rules.
- Server: per-service handlers in src/server/connect/ reuse the existing
  ClickHouse actions/streamers; mounted via @connectrpc/connect-next at
  src/pages/api/[[...connect]].ts. A protovalidate interceptor enforces the
  buf.validate rules on every request before handlers run.
- Client: connect-web transport + connect-query TransportProvider. Simple
  hooks (stats, neighbors, all-neighbors) use connect-query; node/search/chat
  and the map's imperative fetch use generated promise clients. Hooks map the
  generated camelCase messages back to the existing snake_case shapes so
  components are unchanged.
- Chat: live updates now come from the StreamChat server-streaming RPC
  (history still paged via GetChat), replacing the 5s polling query.
- Two SSE endpoints become Connect server-streaming (StreamChat, StreamPackets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-05-29 04:03:22 -04:00
parent 1940f995dc
commit c8792cb187
42 changed files with 8599 additions and 375 deletions
+8
View File
@@ -0,0 +1,8 @@
version: v2
clean: true
plugins:
- local: protoc-gen-es
out: src/gen
opt:
- target=ts
- import_extension=none
+6
View File
@@ -0,0 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/bufbuild/protovalidate
commit: 50325440f8f24053b047484a6bf60b76
digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31
+11
View File
@@ -0,0 +1,11 @@
version: v2
modules:
- path: proto
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- STANDARD
breaking:
use:
- FILE
+334 -3
View File
@@ -8,7 +8,13 @@
"name": "meshexplorer",
"version": "0.1.0",
"dependencies": {
"@bufbuild/protobuf": "^2.9.0",
"@bufbuild/protovalidate": "^1.2.0",
"@clickhouse/client": "^1.11.2",
"@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-next": "^2.1.0",
"@connectrpc/connect-query": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.87.1",
@@ -35,6 +41,8 @@
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@bufbuild/buf": "^1.50.0",
"@bufbuild/protoc-gen-es": "^2.9.0",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/aes-js": "^3.1.4",
@@ -99,6 +107,240 @@
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@bufbuild/buf": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.70.0.tgz",
"integrity": "sha512-oJWGqltlu8F7VVNHLoJ3pFXhjfiGpbh7+/mXW0y+VMPWFGxc9YDv4de1UcX7zhhjV6MbE4SiEGo5Gs5jhpVg5A==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"bin": {
"buf": "bin/buf",
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@bufbuild/buf-darwin-arm64": "1.70.0",
"@bufbuild/buf-darwin-x64": "1.70.0",
"@bufbuild/buf-linux-aarch64": "1.70.0",
"@bufbuild/buf-linux-armv7": "1.70.0",
"@bufbuild/buf-linux-x64": "1.70.0",
"@bufbuild/buf-win32-arm64": "1.70.0",
"@bufbuild/buf-win32-x64": "1.70.0"
}
},
"node_modules/@bufbuild/buf-darwin-arm64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.70.0.tgz",
"integrity": "sha512-c7owUswBbMmwfHPH9JRBEJu09mrXYGC33V2JQCgraWCBm74Z95AOkhDua50qiBrQnysvJkJ0p/z4MWxJqcpnIA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-darwin-x64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.70.0.tgz",
"integrity": "sha512-sucV3lQXVuOqYs3+ToulkUh2tZuMnl286DKb44imp3PnexVhAVOP7d3ybYe98HNGwysEdjNP2WIOGb0uKuRCIQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-aarch64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.70.0.tgz",
"integrity": "sha512-4viSYqbhIusd6LR+JayDex8S1rLUL+hTUMYUgSPl75EC93FpJM4vkk2RhoAhyjQqWF/JQLcyWV8kjRRiIwygdg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-armv7": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.70.0.tgz",
"integrity": "sha512-GqujpTX4MXtYiUkxd6oI1g0JaCX3L6koT16Gl0D0HIQ/V2mptH7x4UW8nK3tAURMjrHsEEhcSJRtmfINTTKnsg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-x64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.70.0.tgz",
"integrity": "sha512-5WHGUIb5iLFXcnqV33TDejqaPgx0CWFaYW7b4wh12wT0w3DR+ghFq6S6RmYyZLbTuhS4ZFsf+xyk5m+HViKxrA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-arm64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.70.0.tgz",
"integrity": "sha512-dU1qh7iD08/1avCHwIOoGsatQctE6uGwgOue9GOaThi8/Rdy1x9CC/eFdyFSeCMwbUg9ABQvMGUocylfUe6xDw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-x64": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.70.0.tgz",
"integrity": "sha512-iKYbjTbEk0ppkv2SrsPFhYus93kj/aMN8aRsrpuo91ZVqXg8JcH4XXbgFpwiFAsiABjqKICFfnDomrFvv49UOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/cel": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@bufbuild/cel/-/cel-0.4.0.tgz",
"integrity": "sha512-CdW/JgiTJCYXqnwuaJRo7NcoYhR37AaF58MMiog0/t8nudn86ZyLXYaA1f2yGhm2U17h8pKGZNksTHVSTcpmAw==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/cel-spec": "0.4.0"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.6.2"
}
},
"node_modules/@bufbuild/cel-spec": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@bufbuild/cel-spec/-/cel-spec-0.4.0.tgz",
"integrity": "sha512-dUS6f2fNt6KEumsYGE7YFxERZE5ZuyME1hQmGjtO8tkZhR6ow6/ne3v4Gik9cfdb9lSLK3AJ+vDxCdGWmDbWvA==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.6.2"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz",
"integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bufbuild/protoc-gen-es": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.0.tgz",
"integrity": "sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.12.0",
"@bufbuild/protoplugin": "2.12.0"
},
"bin": {
"protoc-gen-es": "bin/protoc-gen-es"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "2.12.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
}
}
},
"node_modules/@bufbuild/protoplugin": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.0.tgz",
"integrity": "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.12.0",
"@typescript/vfs": "^1.6.2",
"typescript": "5.4.5"
}
},
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@bufbuild/protovalidate": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protovalidate/-/protovalidate-1.2.0.tgz",
"integrity": "sha512-tD08DwGrHIV88khLz1Kdz9DYUwEo1TsoepaIg7/B/zd//AymaTx67kvCS1jlkcG/+vgQaSOl6f0HCE3sL8vI7w==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/cel": "0.4.0"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.8.0"
}
},
"node_modules/@clickhouse/client": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.11.2.tgz",
@@ -115,6 +357,81 @@
"resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.11.2.tgz",
"integrity": "sha512-H4ECHqaipzMgiZKqpb1Z4N3Ofq+lVTCn8I59XsSynqrsfR4jWZD3PipXVvIzMpDmTMvrlJWrOwAdm0DMNiMQbA=="
},
"node_modules/@connectrpc/connect": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0"
}
},
"node_modules/@connectrpc/connect-next": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-next/-/connect-next-2.1.1.tgz",
"integrity": "sha512-Ch3CmGas7QngT5P2DzS/8xUolZPaN2/EHdfl8F+Bsf6KVi9TZ3wIIVw2879e669xEB3ck7SYv6zVLZ9fkUOa0Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1",
"@connectrpc/connect-node": "2.1.1",
"next": "^13.2.4 || ^14.2.5 || ^15.0.2"
}
},
"node_modules/@connectrpc/connect-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.1.tgz",
"integrity": "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1"
}
},
"node_modules/@connectrpc/connect-query": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-query/-/connect-query-2.2.0.tgz",
"integrity": "sha512-oQ+coXOwBmfl4/t6EOrTfzW0zdoGDe3kvUYqZHrbzORkRFd693Cz8PxuDBjRCjmBPhDRCQMxFhR1jn2+SbgEKQ==",
"license": "Apache-2.0",
"dependencies": {
"@connectrpc/connect-query-core": "^2.2.0"
},
"peerDependencies": {
"@bufbuild/protobuf": "2.x",
"@connectrpc/connect": "^2.0.1",
"@tanstack/react-query": ">=5.62.0",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@connectrpc/connect-query-core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-query-core/-/connect-query-core-2.2.0.tgz",
"integrity": "sha512-t/CuxW/vP84y2iyS+PnbAnBwgOTYMzHXTSoBUKC1vIn706aNiZP40Y6mGJybglyH63RhAPcOdUgzG7DjzaAHCw==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "2.x",
"@connectrpc/connect": "^2.0.1",
"@tanstack/query-core": ">=5.62.0"
}
},
"node_modules/@connectrpc/connect-web": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz",
"integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.7.0",
"@connectrpc/connect": "2.1.1"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -2266,6 +2583,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.10.1.tgz",
@@ -3325,10 +3655,11 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
+9
View File
@@ -7,11 +7,18 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate": "buf generate --include-imports",
"discord-bot": "tsx scripts/discord-bot.ts",
"discord-bot:dev": "tsx watch scripts/discord-bot.ts"
},
"dependencies": {
"@bufbuild/protobuf": "^2.9.0",
"@bufbuild/protovalidate": "^1.2.0",
"@clickhouse/client": "^1.11.2",
"@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-next": "^2.1.0",
"@connectrpc/connect-query": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.87.1",
@@ -38,6 +45,8 @@
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@bufbuild/buf": "^1.50.0",
"@bufbuild/protoc-gen-es": "^2.9.0",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/aes-js": "^3.1.4",
@@ -0,0 +1,68 @@
syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
// One (origin, origin_pubkey, path, broker, topic) reception tuple.
message OriginPathInfo {
string origin = 1;
string origin_pubkey = 2;
string path = 3;
string broker = 4;
string topic = 5;
}
// A parsed/decrypted public-channel message (parseMeshcoreGroupMessage).
message DecryptedChat {
double timestamp = 1;
int32 msg_type = 2;
string sender = 3;
string text = 4;
string raw_text = 5;
}
message ChatMessage {
string ingest_timestamp = 1;
string mesh_timestamp = 2;
string channel_hash = 3;
string mac = 4;
// Hex-encoded ciphertext.
string encrypted_message = 5;
int32 message_count = 6;
repeated OriginPathInfo origin_path_info = 7;
string message_id = 8;
// Present only when decrypt was requested and decryption succeeded.
optional DecryptedChat decrypted = 9;
}
message GetChatRequest {
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]+$"];
optional string region = 5;
bool decrypt = 6;
repeated string private_keys = 7;
}
message GetChatResponse {
repeated ChatMessage messages = 1;
}
message StreamChatRequest {
optional string channel_id = 1 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
optional string region = 2;
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}];
// Max rows per poll (clamped 10..1000, default 500).
optional int32 max_rows = 6 [(buf.validate.field).int32 = {gte: 10, lte: 1000}];
bool skip_initial_messages = 7;
}
service ChatService {
rpc GetChat(GetChatRequest) returns (GetChatResponse);
rpc StreamChat(StreamChatRequest) returns (stream ChatMessage);
}
@@ -0,0 +1,21 @@
syntax = "proto3";
package meshexplorer.v1;
// A directed edge in the neighbor graph between two located nodes.
// Shared by MapService.GetMap (when includeNeighbors=true) and
// NeighborsService.GetAllNeighbors. Mirrors meshcore_all_neighbor_edges.
message NeighborEdge {
string source_node = 1;
string target_node = 2;
string connection_type = 3;
int32 packet_count = 4;
string source_name = 5;
double source_latitude = 6;
double source_longitude = 7;
int32 source_has_location = 8;
string target_name = 9;
double target_latitude = 10;
double target_longitude = 11;
int32 target_has_location = 12;
}
@@ -0,0 +1,43 @@
syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/common.proto";
// Latest known position of a node. Mirrors unified_latest_nodeinfo rows
// returned by getNodePositions().
message NodePosition {
string node_id = 1;
optional string name = 2;
optional string short_name = 3;
double latitude = 4;
double longitude = 5;
string last_seen = 6;
optional string first_seen = 7;
string type = 8;
}
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}];
repeated string node_types = 5;
// Only include nodes seen within this many seconds.
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
optional string region = 7;
// When true, also compute and return the neighbor edge graph.
bool include_neighbors = 8;
}
message GetMapResponse {
repeated NodePosition nodes = 1;
// Populated only when include_neighbors was set on the request.
repeated NeighborEdge neighbors = 2;
}
service MapService {
rpc GetMap(GetMapRequest) returns (GetMapResponse);
}
@@ -0,0 +1,24 @@
syntax = "proto3";
package meshexplorer.v1;
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}];
repeated string node_types = 5;
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
optional string region = 7;
}
message GetAllNeighborsResponse {
repeated NeighborEdge neighbors = 1;
}
service NeighborsService {
rpc GetAllNeighbors(GetAllNeighborsRequest) returns (GetAllNeighborsResponse);
}
@@ -0,0 +1,147 @@
syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
// Basic node identity/capabilities from the latest advert (getMeshcoreNodeInfo).
message NodeInfo {
string public_key = 1;
string node_name = 2;
optional double latitude = 3;
optional double longitude = 4;
int32 has_location = 5;
int32 is_repeater = 6;
int32 is_chat_node = 7;
int32 is_room_server = 8;
int32 has_name = 9;
optional string broker = 10;
optional string topic = 11;
string first_seen = 12;
string last_seen = 13;
}
// One (origin, path, origin_pubkey) tuple from an advert's reception paths.
message OriginPathPubkeyTuple {
string origin = 1;
string path = 2;
string origin_pubkey = 3;
}
// An advert grouped by packet_hash, with all the paths it was heard over.
message Advert {
string adv_timestamp = 1;
repeated OriginPathPubkeyTuple origin_path_pubkey_tuples = 2;
int32 advert_count = 3;
string earliest_timestamp = 4;
string latest_timestamp = 5;
optional double latitude = 6;
optional double longitude = 7;
int32 is_repeater = 8;
int32 is_chat_node = 9;
int32 is_room_server = 10;
int32 has_location = 11;
string packet_hash = 12;
}
message LocationHistory {
string mesh_timestamp = 1;
double latitude = 2;
double longitude = 3;
}
message MqttTopic {
string topic = 1;
string broker = 2;
string last_packet_time = 3;
bool is_recent = 4;
}
message MqttInfo {
bool is_uplinked = 1;
bool has_packets = 2;
repeated MqttTopic topics = 3;
}
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}];
}
message GetNodeResponse {
NodeInfo node = 1;
repeated Advert recent_adverts = 2;
repeated LocationHistory location_history = 3;
MqttInfo mqtt = 4;
optional string region = 5;
}
// Direct neighbor of a node (meshcore_node_direct_neighbors).
message Neighbor {
string public_key = 1;
string node_name = 2;
optional double latitude = 3;
optional double longitude = 4;
int32 has_location = 5;
int32 is_repeater = 6;
int32 is_chat_node = 7;
int32 is_room_server = 8;
int32 has_name = 9;
repeated string directions = 10;
}
message GetNodeNeighborsRequest {
string public_key = 1 [(buf.validate.field).string.min_len = 10];
optional int32 last_seen = 2 [(buf.validate.field).int32.gte = 0];
}
message GetNodeNeighborsResponse {
repeated Neighbor neighbors = 1;
}
// One search request within a (possibly batched) SearchNodes call.
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 bool exact = 5;
optional bool is_repeater = 6;
}
message SearchResult {
string public_key = 1;
string node_name = 2;
optional double latitude = 3;
optional double longitude = 4;
int32 has_location = 5;
int32 is_repeater = 6;
int32 is_chat_node = 7;
int32 is_room_server = 8;
int32 has_name = 9;
string first_heard = 10;
string last_seen = 11;
string broker = 12;
string topic = 13;
}
// Results for a single query in the batch (preserves per-query grouping).
message SearchResultList {
repeated SearchResult results = 1;
}
message SearchNodesRequest {
repeated SearchQuery queries = 1 [(buf.validate.field).repeated.max_items = 500];
}
message SearchNodesResponse {
// One entry per input query, in the same order.
repeated SearchResultList results = 1;
}
service NodeService {
rpc GetNode(GetNodeRequest) returns (GetNodeResponse);
rpc GetNodeNeighbors(GetNodeNeighborsRequest) returns (GetNodeNeighborsResponse);
rpc SearchNodes(SearchNodesRequest) returns (SearchNodesResponse);
}
@@ -0,0 +1,38 @@
syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
// A raw mesh packet (meshcore_packets), hex fields kept as hex strings.
message Packet {
string ingest_timestamp = 1;
string mesh_timestamp = 2;
string broker = 3;
string topic = 4;
string packet = 5;
int32 path_len = 6;
string path = 7;
int32 route_type = 8;
int32 payload_type = 9;
int32 payload_version = 10;
int32 header = 11;
string origin_pubkey = 12;
}
message StreamPacketsRequest {
optional string region = 1;
// Payload type filter (0..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 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}];
// Max rows per poll (clamped 10..10000, default 10).
optional int32 max_rows = 6 [(buf.validate.field).int32 = {gte: 10, lte: 10000}];
}
service PacketsService {
rpc StreamPackets(StreamPacketsRequest) returns (stream Packet);
}
@@ -0,0 +1,49 @@
syntax = "proto3";
package meshexplorer.v1;
message StatsRequest {
optional string region = 1;
}
message GetTotalNodesResponse {
int32 total_nodes = 1;
}
message NodesOverTimeRow {
string day = 1;
int32 cumulative_unique_nodes = 2;
int32 nodes_with_location = 3;
int32 nodes_without_location = 4;
int32 repeaters = 5;
int32 room_servers = 6;
}
message GetNodesOverTimeResponse {
repeated NodesOverTimeRow data = 1;
}
message PopularChannelRow {
string channel_hash = 1;
int32 message_count = 2;
}
message GetPopularChannelsResponse {
repeated PopularChannelRow data = 1;
}
message RepeaterPrefixRow {
string prefix = 1;
repeated string node_names = 2;
}
message GetRepeaterPrefixesResponse {
repeated RepeaterPrefixRow data = 1;
}
service StatsService {
rpc GetTotalNodes(StatsRequest) returns (GetTotalNodesResponse);
rpc GetNodesOverTime(StatsRequest) returns (GetNodesOverTimeResponse);
rpc GetPopularChannels(StatsRequest) returns (GetPopularChannelsResponse);
rpc GetRepeaterPrefixes(StatsRequest) returns (GetRepeaterPrefixesResponse);
}
@@ -25,7 +25,7 @@ function getNodeType(node: NodeInfo): number {
export default function MeshcoreNodePage() {
const params = useParams();
const publicKey = params.publicKey as string;
const publicKey = params?.publicKey as string;
const { config } = useConfig();
// Use TanStack Query for node data
+62 -51
View File
@@ -13,7 +13,8 @@ import MapLayerSettingsComponent from "@/components/MapLayerSettings";
import { type MapLayerSettings } from "@/hooks/useMapLayerSettings";
import { NodeMarker, ClusterMarker, PopupContent } from "./MapIcons";
import { renderToString } from "react-dom/server";
import { buildApiUrl } from "@/lib/api";
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";
@@ -572,65 +573,75 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
setAllNeighborsLoading(true);
}
let url = "/api/map";
const params = [];
if (bounds) {
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
params.push(`minLat=${minLat}`);
params.push(`maxLat=${maxLat}`);
params.push(`minLng=${minLng}`);
params.push(`maxLng=${maxLng}`);
}
if (mapLayerSettings.nodeTypes && mapLayerSettings.nodeTypes.length > 0) {
for (const type of mapLayerSettings.nodeTypes) {
params.push(`nodeTypes=${encodeURIComponent(type)}`);
}
}
if (config?.lastSeen !== null && config?.lastSeen !== undefined) {
params.push(`lastSeen=${config.lastSeen}`);
}
if (config?.selectedRegion) {
params.push(`region=${encodeURIComponent(config.selectedRegion)}`);
}
if (includeNeighbors) {
params.push('includeNeighbors=true');
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
fetch(buildApiUrl(url), { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
// Backward compatibility: just nodes array
setNodePositions(data);
setLastResultCount(data.length);
if (includeNeighbors) {
// If we expected neighbors but got just nodes, clear neighbors
setAllNeighborConnections([]);
}
} else if (data && data.nodes && Array.isArray(data.nodes)) {
// New format: object with nodes and neighbors
setNodePositions(data.nodes);
setLastResultCount(data.nodes.length);
if (data.neighbors && Array.isArray(data.neighbors)) {
setAllNeighborConnections(data.neighbors);
} else {
setAllNeighborConnections([]);
}
// Clamp to valid lat/lng ranges so the protovalidate bounds rules accept the
// (buffered) viewport; node coordinates never fall outside these ranges.
const clampLat = (v: number) => Math.max(-90, Math.min(90, v));
const clampLng = (v: number) => Math.max(-180, Math.min(180, v));
const request = {
minLat: bounds ? clampLat(bounds[0][0]) : undefined,
minLng: bounds ? clampLng(bounds[0][1]) : undefined,
maxLat: bounds ? clampLat(bounds[1][0]) : undefined,
maxLng: bounds ? clampLng(bounds[1][1]) : undefined,
nodeTypes:
mapLayerSettings.nodeTypes && mapLayerSettings.nodeTypes.length > 0
? mapLayerSettings.nodeTypes
: [],
lastSeen:
config?.lastSeen !== null && config?.lastSeen !== undefined
? config.lastSeen
: undefined,
region: config?.selectedRegion || undefined,
includeNeighbors,
};
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,
})),
);
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 {
setNodePositions([]);
setAllNeighborConnections([]);
}
if (fetchController.current === controller) {
setLoading(false);
setAllNeighborsLoading(false);
}
})
.catch((err) => {
if (err.name !== "AbortError") {
const canceled =
controller.signal.aborted ||
(err instanceof ConnectError && err.code === Code.Canceled);
if (!canceled) {
setNodePositions([]);
setAllNeighborConnections([]);
}
@@ -1,7 +1,9 @@
"use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TransportProvider } from '@connectrpc/connect-query';
import { ReactNode, useState } from 'react';
import { transport } from '@/lib/connect/transport';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
@@ -16,8 +18,10 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<TransportProvider transport={transport}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TransportProvider>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,292 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/chat.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../buf/validate/validate_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/chat.proto.
*/
export const file_meshexplorer_v1_chat: GenFile = /*@__PURE__*/
fileDesc("ChptZXNoZXhwbG9yZXIvdjEvY2hhdC5wcm90bxIPbWVzaGV4cGxvcmVyLnYxImQKDk9yaWdpblBhdGhJbmZvEg4KBm9yaWdpbhgBIAEoCRIVCg1vcmlnaW5fcHVia2V5GAIgASgJEgwKBHBhdGgYAyABKAkSDgoGYnJva2VyGAQgASgJEg0KBXRvcGljGAUgASgJImQKDURlY3J5cHRlZENoYXQSEQoJdGltZXN0YW1wGAEgASgBEhAKCG1zZ190eXBlGAIgASgFEg4KBnNlbmRlchgDIAEoCRIMCgR0ZXh0GAQgASgJEhAKCHJhd190ZXh0GAUgASgJIqkCCgtDaGF0TWVzc2FnZRIYChBpbmdlc3RfdGltZXN0YW1wGAEgASgJEhYKDm1lc2hfdGltZXN0YW1wGAIgASgJEhQKDGNoYW5uZWxfaGFzaBgDIAEoCRILCgNtYWMYBCABKAkSGQoRZW5jcnlwdGVkX21lc3NhZ2UYBSABKAkSFQoNbWVzc2FnZV9jb3VudBgGIAEoBRI5ChBvcmlnaW5fcGF0aF9pbmZvGAcgAygLMh8ubWVzaGV4cGxvcmVyLnYxLk9yaWdpblBhdGhJbmZvEhIKCm1lc3NhZ2VfaWQYCCABKAkSNgoJZGVjcnlwdGVkGAkgASgLMh4ubWVzaGV4cGxvcmVyLnYxLkRlY3J5cHRlZENoYXRIAIgBAUIMCgpfZGVjcnlwdGVkIv4BCg5HZXRDaGF0UmVxdWVzdBIeCgVsaW1pdBgBIAEoBUIKukgHGgUY6AcoAUgAiAEBEhMKBmJlZm9yZRgCIAEoCUgBiAEBEhIKBWFmdGVyGAMgASgJSAKIAQESLgoKY2hhbm5lbF9pZBgEIAEoCUIVukgSchAyDl5bMC05QS1GYS1mXSskSAOIAQESEwoGcmVnaW9uGAUgASgJSASIAQESDwoHZGVjcnlwdBgGIAEoCBIUCgxwcml2YXRlX2tleXMYByADKAlCCAoGX2xpbWl0QgkKB19iZWZvcmVCCAoGX2FmdGVyQg0KC19jaGFubmVsX2lkQgkKB19yZWdpb24iQQoPR2V0Q2hhdFJlc3BvbnNlEi4KCG1lc3NhZ2VzGAEgAygLMhwubWVzaGV4cGxvcmVyLnYxLkNoYXRNZXNzYWdlIqICChFTdHJlYW1DaGF0UmVxdWVzdBIuCgpjaGFubmVsX2lkGAEgASgJQhW6SBJyEDIOXlswLTlBLUZhLWZdKyRIAIgBARITCgZyZWdpb24YAiABKAlIAYgBARIPCgdkZWNyeXB0GAMgASgIEhQKDHByaXZhdGVfa2V5cxgEIAMoCRImCg1wb2xsX2ludGVydmFsGAUgASgFQgq6SAcaBRiQTihkSAKIAQESIQoIbWF4X3Jvd3MYBiABKAVCCrpIBxoFGOgHKApIA4gBARIdChVza2lwX2luaXRpYWxfbWVzc2FnZXMYByABKAhCDQoLX2NoYW5uZWxfaWRCCQoHX3JlZ2lvbkIQCg5fcG9sbF9pbnRlcnZhbEILCglfbWF4X3Jvd3MyrQEKC0NoYXRTZXJ2aWNlEkwKB0dldENoYXQSHy5tZXNoZXhwbG9yZXIudjEuR2V0Q2hhdFJlcXVlc3QaIC5tZXNoZXhwbG9yZXIudjEuR2V0Q2hhdFJlc3BvbnNlElAKClN0cmVhbUNoYXQSIi5tZXNoZXhwbG9yZXIudjEuU3RyZWFtQ2hhdFJlcXVlc3QaHC5tZXNoZXhwbG9yZXIudjEuQ2hhdE1lc3NhZ2UwAWIGcHJvdG8z", [file_buf_validate_validate]);
/**
* One (origin, origin_pubkey, path, broker, topic) reception tuple.
*
* @generated from message meshexplorer.v1.OriginPathInfo
*/
export type OriginPathInfo = Message<"meshexplorer.v1.OriginPathInfo"> & {
/**
* @generated from field: string origin = 1;
*/
origin: string;
/**
* @generated from field: string origin_pubkey = 2;
*/
originPubkey: string;
/**
* @generated from field: string path = 3;
*/
path: string;
/**
* @generated from field: string broker = 4;
*/
broker: string;
/**
* @generated from field: string topic = 5;
*/
topic: string;
};
/**
* Describes the message meshexplorer.v1.OriginPathInfo.
* Use `create(OriginPathInfoSchema)` to create a new message.
*/
export const OriginPathInfoSchema: GenMessage<OriginPathInfo> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 0);
/**
* A parsed/decrypted public-channel message (parseMeshcoreGroupMessage).
*
* @generated from message meshexplorer.v1.DecryptedChat
*/
export type DecryptedChat = Message<"meshexplorer.v1.DecryptedChat"> & {
/**
* @generated from field: double timestamp = 1;
*/
timestamp: number;
/**
* @generated from field: int32 msg_type = 2;
*/
msgType: number;
/**
* @generated from field: string sender = 3;
*/
sender: string;
/**
* @generated from field: string text = 4;
*/
text: string;
/**
* @generated from field: string raw_text = 5;
*/
rawText: string;
};
/**
* Describes the message meshexplorer.v1.DecryptedChat.
* Use `create(DecryptedChatSchema)` to create a new message.
*/
export const DecryptedChatSchema: GenMessage<DecryptedChat> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 1);
/**
* @generated from message meshexplorer.v1.ChatMessage
*/
export type ChatMessage = Message<"meshexplorer.v1.ChatMessage"> & {
/**
* @generated from field: string ingest_timestamp = 1;
*/
ingestTimestamp: string;
/**
* @generated from field: string mesh_timestamp = 2;
*/
meshTimestamp: string;
/**
* @generated from field: string channel_hash = 3;
*/
channelHash: string;
/**
* @generated from field: string mac = 4;
*/
mac: string;
/**
* Hex-encoded ciphertext.
*
* @generated from field: string encrypted_message = 5;
*/
encryptedMessage: string;
/**
* @generated from field: int32 message_count = 6;
*/
messageCount: number;
/**
* @generated from field: repeated meshexplorer.v1.OriginPathInfo origin_path_info = 7;
*/
originPathInfo: OriginPathInfo[];
/**
* @generated from field: string message_id = 8;
*/
messageId: string;
/**
* Present only when decrypt was requested and decryption succeeded.
*
* @generated from field: optional meshexplorer.v1.DecryptedChat decrypted = 9;
*/
decrypted?: DecryptedChat | undefined;
};
/**
* Describes the message meshexplorer.v1.ChatMessage.
* Use `create(ChatMessageSchema)` to create a new message.
*/
export const ChatMessageSchema: GenMessage<ChatMessage> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 2);
/**
* @generated from message meshexplorer.v1.GetChatRequest
*/
export type GetChatRequest = Message<"meshexplorer.v1.GetChatRequest"> & {
/**
* @generated from field: optional int32 limit = 1;
*/
limit?: number | undefined;
/**
* @generated from field: optional string before = 2;
*/
before?: string | undefined;
/**
* @generated from field: optional string after = 3;
*/
after?: string | undefined;
/**
* @generated from field: optional string channel_id = 4;
*/
channelId?: string | undefined;
/**
* @generated from field: optional string region = 5;
*/
region?: string | undefined;
/**
* @generated from field: bool decrypt = 6;
*/
decrypt: boolean;
/**
* @generated from field: repeated string private_keys = 7;
*/
privateKeys: string[];
};
/**
* Describes the message meshexplorer.v1.GetChatRequest.
* Use `create(GetChatRequestSchema)` to create a new message.
*/
export const GetChatRequestSchema: GenMessage<GetChatRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 3);
/**
* @generated from message meshexplorer.v1.GetChatResponse
*/
export type GetChatResponse = Message<"meshexplorer.v1.GetChatResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.ChatMessage messages = 1;
*/
messages: ChatMessage[];
};
/**
* Describes the message meshexplorer.v1.GetChatResponse.
* Use `create(GetChatResponseSchema)` to create a new message.
*/
export const GetChatResponseSchema: GenMessage<GetChatResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 4);
/**
* @generated from message meshexplorer.v1.StreamChatRequest
*/
export type StreamChatRequest = Message<"meshexplorer.v1.StreamChatRequest"> & {
/**
* @generated from field: optional string channel_id = 1;
*/
channelId?: string | undefined;
/**
* @generated from field: optional string region = 2;
*/
region?: string | undefined;
/**
* @generated from field: bool decrypt = 3;
*/
decrypt: boolean;
/**
* @generated from field: repeated string private_keys = 4;
*/
privateKeys: string[];
/**
* Poll interval in ms (clamped 100..10000, default 1000).
*
* @generated from field: optional int32 poll_interval = 5;
*/
pollInterval?: number | undefined;
/**
* Max rows per poll (clamped 10..1000, default 500).
*
* @generated from field: optional int32 max_rows = 6;
*/
maxRows?: number | undefined;
/**
* @generated from field: bool skip_initial_messages = 7;
*/
skipInitialMessages: boolean;
};
/**
* Describes the message meshexplorer.v1.StreamChatRequest.
* Use `create(StreamChatRequestSchema)` to create a new message.
*/
export const StreamChatRequestSchema: GenMessage<StreamChatRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_chat, 5);
/**
* @generated from service meshexplorer.v1.ChatService
*/
export const ChatService: GenService<{
/**
* @generated from rpc meshexplorer.v1.ChatService.GetChat
*/
getChat: {
methodKind: "unary";
input: typeof GetChatRequestSchema;
output: typeof GetChatResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.ChatService.StreamChat
*/
streamChat: {
methodKind: "server_streaming";
input: typeof StreamChatRequestSchema;
output: typeof ChatMessageSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_chat, 0);
@@ -0,0 +1,90 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/common.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/common.proto.
*/
export const file_meshexplorer_v1_common: GenFile = /*@__PURE__*/
fileDesc("ChxtZXNoZXhwbG9yZXIvdjEvY29tbW9uLnByb3RvEg9tZXNoZXhwbG9yZXIudjEisQIKDE5laWdoYm9yRWRnZRITCgtzb3VyY2Vfbm9kZRgBIAEoCRITCgt0YXJnZXRfbm9kZRgCIAEoCRIXCg9jb25uZWN0aW9uX3R5cGUYAyABKAkSFAoMcGFja2V0X2NvdW50GAQgASgFEhMKC3NvdXJjZV9uYW1lGAUgASgJEhcKD3NvdXJjZV9sYXRpdHVkZRgGIAEoARIYChBzb3VyY2VfbG9uZ2l0dWRlGAcgASgBEhsKE3NvdXJjZV9oYXNfbG9jYXRpb24YCCABKAUSEwoLdGFyZ2V0X25hbWUYCSABKAkSFwoPdGFyZ2V0X2xhdGl0dWRlGAogASgBEhgKEHRhcmdldF9sb25naXR1ZGUYCyABKAESGwoTdGFyZ2V0X2hhc19sb2NhdGlvbhgMIAEoBWIGcHJvdG8z");
/**
* A directed edge in the neighbor graph between two located nodes.
* Shared by MapService.GetMap (when includeNeighbors=true) and
* NeighborsService.GetAllNeighbors. Mirrors meshcore_all_neighbor_edges.
*
* @generated from message meshexplorer.v1.NeighborEdge
*/
export type NeighborEdge = Message<"meshexplorer.v1.NeighborEdge"> & {
/**
* @generated from field: string source_node = 1;
*/
sourceNode: string;
/**
* @generated from field: string target_node = 2;
*/
targetNode: string;
/**
* @generated from field: string connection_type = 3;
*/
connectionType: string;
/**
* @generated from field: int32 packet_count = 4;
*/
packetCount: number;
/**
* @generated from field: string source_name = 5;
*/
sourceName: string;
/**
* @generated from field: double source_latitude = 6;
*/
sourceLatitude: number;
/**
* @generated from field: double source_longitude = 7;
*/
sourceLongitude: number;
/**
* @generated from field: int32 source_has_location = 8;
*/
sourceHasLocation: number;
/**
* @generated from field: string target_name = 9;
*/
targetName: string;
/**
* @generated from field: double target_latitude = 10;
*/
targetLatitude: number;
/**
* @generated from field: double target_longitude = 11;
*/
targetLongitude: number;
/**
* @generated from field: int32 target_has_location = 12;
*/
targetHasLocation: number;
};
/**
* Describes the message meshexplorer.v1.NeighborEdge.
* Use `create(NeighborEdgeSchema)` to create a new message.
*/
export const NeighborEdgeSchema: GenMessage<NeighborEdge> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_common, 0);
@@ -0,0 +1,169 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/map.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../buf/validate/validate_pb";
import type { NeighborEdge } from "./common_pb";
import { file_meshexplorer_v1_common } from "./common_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/map.proto.
*/
export const file_meshexplorer_v1_map: GenFile = /*@__PURE__*/
fileDesc("ChltZXNoZXhwbG9yZXIvdjEvbWFwLnByb3RvEg9tZXNoZXhwbG9yZXIudjEi0QEKDE5vZGVQb3NpdGlvbhIPCgdub2RlX2lkGAEgASgJEhEKBG5hbWUYAiABKAlIAIgBARIXCgpzaG9ydF9uYW1lGAMgASgJSAGIAQESEAoIbGF0aXR1ZGUYBCABKAESEQoJbG9uZ2l0dWRlGAUgASgBEhEKCWxhc3Rfc2VlbhgGIAEoCRIXCgpmaXJzdF9zZWVuGAcgASgJSAKIAQESDAoEdHlwZRgIIAEoCUIHCgVfbmFtZUINCgtfc2hvcnRfbmFtZUINCgtfZmlyc3Rfc2VlbiL5AgoNR2V0TWFwUmVxdWVzdBItCgdtaW5fbGF0GAEgASgBQhe6SBQSEhkAAAAAAIBWQCkAAAAAAIBWwEgAiAEBEi0KB21heF9sYXQYAiABKAFCF7pIFBISGQAAAAAAgFZAKQAAAAAAgFbASAGIAQESLQoHbWluX2xuZxgDIAEoAUIXukgUEhIZAAAAAACAZkApAAAAAACAZsBIAogBARItCgdtYXhfbG5nGAQgASgBQhe6SBQSEhkAAAAAAIBmQCkAAAAAAIBmwEgDiAEBEhIKCm5vZGVfdHlwZXMYBSADKAkSHwoJbGFzdF9zZWVuGAYgASgFQge6SAQaAigASASIAQESEwoGcmVnaW9uGAcgASgJSAWIAQESGQoRaW5jbHVkZV9uZWlnaGJvcnMYCCABKAhCCgoIX21pbl9sYXRCCgoIX21heF9sYXRCCgoIX21pbl9sbmdCCgoIX21heF9sbmdCDAoKX2xhc3Rfc2VlbkIJCgdfcmVnaW9uInAKDkdldE1hcFJlc3BvbnNlEiwKBW5vZGVzGAEgAygLMh0ubWVzaGV4cGxvcmVyLnYxLk5vZGVQb3NpdGlvbhIwCgluZWlnaGJvcnMYAiADKAsyHS5tZXNoZXhwbG9yZXIudjEuTmVpZ2hib3JFZGdlMlcKCk1hcFNlcnZpY2USSQoGR2V0TWFwEh4ubWVzaGV4cGxvcmVyLnYxLkdldE1hcFJlcXVlc3QaHy5tZXNoZXhwbG9yZXIudjEuR2V0TWFwUmVzcG9uc2ViBnByb3RvMw", [file_buf_validate_validate, file_meshexplorer_v1_common]);
/**
* Latest known position of a node. Mirrors unified_latest_nodeinfo rows
* returned by getNodePositions().
*
* @generated from message meshexplorer.v1.NodePosition
*/
export type NodePosition = Message<"meshexplorer.v1.NodePosition"> & {
/**
* @generated from field: string node_id = 1;
*/
nodeId: string;
/**
* @generated from field: optional string name = 2;
*/
name?: string | undefined;
/**
* @generated from field: optional string short_name = 3;
*/
shortName?: string | undefined;
/**
* @generated from field: double latitude = 4;
*/
latitude: number;
/**
* @generated from field: double longitude = 5;
*/
longitude: number;
/**
* @generated from field: string last_seen = 6;
*/
lastSeen: string;
/**
* @generated from field: optional string first_seen = 7;
*/
firstSeen?: string | undefined;
/**
* @generated from field: string type = 8;
*/
type: string;
};
/**
* Describes the message meshexplorer.v1.NodePosition.
* Use `create(NodePositionSchema)` to create a new message.
*/
export const NodePositionSchema: GenMessage<NodePosition> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_map, 0);
/**
* @generated from message meshexplorer.v1.GetMapRequest
*/
export type GetMapRequest = Message<"meshexplorer.v1.GetMapRequest"> & {
/**
* Bounding box (decimal degrees). Unset fields mean "unbounded" on that edge.
*
* @generated from field: optional double min_lat = 1;
*/
minLat?: number | undefined;
/**
* @generated from field: optional double max_lat = 2;
*/
maxLat?: number | undefined;
/**
* @generated from field: optional double min_lng = 3;
*/
minLng?: number | undefined;
/**
* @generated from field: optional double max_lng = 4;
*/
maxLng?: number | undefined;
/**
* @generated from field: repeated string node_types = 5;
*/
nodeTypes: string[];
/**
* Only include nodes seen within this many seconds.
*
* @generated from field: optional int32 last_seen = 6;
*/
lastSeen?: number | undefined;
/**
* @generated from field: optional string region = 7;
*/
region?: string | undefined;
/**
* When true, also compute and return the neighbor edge graph.
*
* @generated from field: bool include_neighbors = 8;
*/
includeNeighbors: boolean;
};
/**
* Describes the message meshexplorer.v1.GetMapRequest.
* Use `create(GetMapRequestSchema)` to create a new message.
*/
export const GetMapRequestSchema: GenMessage<GetMapRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_map, 1);
/**
* @generated from message meshexplorer.v1.GetMapResponse
*/
export type GetMapResponse = Message<"meshexplorer.v1.GetMapResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.NodePosition nodes = 1;
*/
nodes: NodePosition[];
/**
* Populated only when include_neighbors was set on the request.
*
* @generated from field: repeated meshexplorer.v1.NeighborEdge neighbors = 2;
*/
neighbors: NeighborEdge[];
};
/**
* Describes the message meshexplorer.v1.GetMapResponse.
* Use `create(GetMapResponseSchema)` to create a new message.
*/
export const GetMapResponseSchema: GenMessage<GetMapResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_map, 2);
/**
* @generated from service meshexplorer.v1.MapService
*/
export const MapService: GenService<{
/**
* @generated from rpc meshexplorer.v1.MapService.GetMap
*/
getMap: {
methodKind: "unary";
input: typeof GetMapRequestSchema;
output: typeof GetMapResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_map, 0);
@@ -0,0 +1,96 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/neighbors.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../buf/validate/validate_pb";
import type { NeighborEdge } from "./common_pb";
import { file_meshexplorer_v1_common } from "./common_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/neighbors.proto.
*/
export const file_meshexplorer_v1_neighbors: GenFile = /*@__PURE__*/
fileDesc("Ch9tZXNoZXhwbG9yZXIvdjEvbmVpZ2hib3JzLnByb3RvEg9tZXNoZXhwbG9yZXIudjEi5wIKFkdldEFsbE5laWdoYm9yc1JlcXVlc3QSLQoHbWluX2xhdBgBIAEoAUIXukgUEhIZAAAAAACAVkApAAAAAACAVsBIAIgBARItCgdtYXhfbGF0GAIgASgBQhe6SBQSEhkAAAAAAIBWQCkAAAAAAIBWwEgBiAEBEi0KB21pbl9sbmcYAyABKAFCF7pIFBISGQAAAAAAgGZAKQAAAAAAgGbASAKIAQESLQoHbWF4X2xuZxgEIAEoAUIXukgUEhIZAAAAAACAZkApAAAAAACAZsBIA4gBARISCgpub2RlX3R5cGVzGAUgAygJEh8KCWxhc3Rfc2VlbhgGIAEoBUIHukgEGgIoAEgEiAEBEhMKBnJlZ2lvbhgHIAEoCUgFiAEBQgoKCF9taW5fbGF0QgoKCF9tYXhfbGF0QgoKCF9taW5fbG5nQgoKCF9tYXhfbG5nQgwKCl9sYXN0X3NlZW5CCQoHX3JlZ2lvbiJLChdHZXRBbGxOZWlnaGJvcnNSZXNwb25zZRIwCgluZWlnaGJvcnMYASADKAsyHS5tZXNoZXhwbG9yZXIudjEuTmVpZ2hib3JFZGdlMngKEE5laWdoYm9yc1NlcnZpY2USZAoPR2V0QWxsTmVpZ2hib3JzEicubWVzaGV4cGxvcmVyLnYxLkdldEFsbE5laWdoYm9yc1JlcXVlc3QaKC5tZXNoZXhwbG9yZXIudjEuR2V0QWxsTmVpZ2hib3JzUmVzcG9uc2ViBnByb3RvMw", [file_buf_validate_validate, file_meshexplorer_v1_common]);
/**
* @generated from message meshexplorer.v1.GetAllNeighborsRequest
*/
export type GetAllNeighborsRequest = Message<"meshexplorer.v1.GetAllNeighborsRequest"> & {
/**
* @generated from field: optional double min_lat = 1;
*/
minLat?: number | undefined;
/**
* @generated from field: optional double max_lat = 2;
*/
maxLat?: number | undefined;
/**
* @generated from field: optional double min_lng = 3;
*/
minLng?: number | undefined;
/**
* @generated from field: optional double max_lng = 4;
*/
maxLng?: number | undefined;
/**
* @generated from field: repeated string node_types = 5;
*/
nodeTypes: string[];
/**
* @generated from field: optional int32 last_seen = 6;
*/
lastSeen?: number | undefined;
/**
* @generated from field: optional string region = 7;
*/
region?: string | undefined;
};
/**
* Describes the message meshexplorer.v1.GetAllNeighborsRequest.
* Use `create(GetAllNeighborsRequestSchema)` to create a new message.
*/
export const GetAllNeighborsRequestSchema: GenMessage<GetAllNeighborsRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_neighbors, 0);
/**
* @generated from message meshexplorer.v1.GetAllNeighborsResponse
*/
export type GetAllNeighborsResponse = Message<"meshexplorer.v1.GetAllNeighborsResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.NeighborEdge neighbors = 1;
*/
neighbors: NeighborEdge[];
};
/**
* Describes the message meshexplorer.v1.GetAllNeighborsResponse.
* Use `create(GetAllNeighborsResponseSchema)` to create a new message.
*/
export const GetAllNeighborsResponseSchema: GenMessage<GetAllNeighborsResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_neighbors, 1);
/**
* @generated from service meshexplorer.v1.NeighborsService
*/
export const NeighborsService: GenService<{
/**
* @generated from rpc meshexplorer.v1.NeighborsService.GetAllNeighbors
*/
getAllNeighbors: {
methodKind: "unary";
input: typeof GetAllNeighborsRequestSchema;
output: typeof GetAllNeighborsResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_neighbors, 0);
@@ -0,0 +1,654 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/node.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../buf/validate/validate_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/node.proto.
*/
export const file_meshexplorer_v1_node: GenFile = /*@__PURE__*/
fileDesc("ChptZXNoZXhwbG9yZXIvdjEvbm9kZS5wcm90bxIPbWVzaGV4cGxvcmVyLnYxIssCCghOb2RlSW5mbxISCgpwdWJsaWNfa2V5GAEgASgJEhEKCW5vZGVfbmFtZRgCIAEoCRIVCghsYXRpdHVkZRgDIAEoAUgAiAEBEhYKCWxvbmdpdHVkZRgEIAEoAUgBiAEBEhQKDGhhc19sb2NhdGlvbhgFIAEoBRITCgtpc19yZXBlYXRlchgGIAEoBRIUCgxpc19jaGF0X25vZGUYByABKAUSFgoOaXNfcm9vbV9zZXJ2ZXIYCCABKAUSEAoIaGFzX25hbWUYCSABKAUSEwoGYnJva2VyGAogASgJSAKIAQESEgoFdG9waWMYCyABKAlIA4gBARISCgpmaXJzdF9zZWVuGAwgASgJEhEKCWxhc3Rfc2VlbhgNIAEoCUILCglfbGF0aXR1ZGVCDAoKX2xvbmdpdHVkZUIJCgdfYnJva2VyQggKBl90b3BpYyJMChVPcmlnaW5QYXRoUHVia2V5VHVwbGUSDgoGb3JpZ2luGAEgASgJEgwKBHBhdGgYAiABKAkSFQoNb3JpZ2luX3B1YmtleRgDIAEoCSLuAgoGQWR2ZXJ0EhUKDWFkdl90aW1lc3RhbXAYASABKAkSSQoZb3JpZ2luX3BhdGhfcHVia2V5X3R1cGxlcxgCIAMoCzImLm1lc2hleHBsb3Jlci52MS5PcmlnaW5QYXRoUHVia2V5VHVwbGUSFAoMYWR2ZXJ0X2NvdW50GAMgASgFEhoKEmVhcmxpZXN0X3RpbWVzdGFtcBgEIAEoCRIYChBsYXRlc3RfdGltZXN0YW1wGAUgASgJEhUKCGxhdGl0dWRlGAYgASgBSACIAQESFgoJbG9uZ2l0dWRlGAcgASgBSAGIAQESEwoLaXNfcmVwZWF0ZXIYCCABKAUSFAoMaXNfY2hhdF9ub2RlGAkgASgFEhYKDmlzX3Jvb21fc2VydmVyGAogASgFEhQKDGhhc19sb2NhdGlvbhgLIAEoBRITCgtwYWNrZXRfaGFzaBgMIAEoCUILCglfbGF0aXR1ZGVCDAoKX2xvbmdpdHVkZSJOCg9Mb2NhdGlvbkhpc3RvcnkSFgoObWVzaF90aW1lc3RhbXAYASABKAkSEAoIbGF0aXR1ZGUYAiABKAESEQoJbG9uZ2l0dWRlGAMgASgBIlcKCU1xdHRUb3BpYxINCgV0b3BpYxgBIAEoCRIOCgZicm9rZXIYAiABKAkSGAoQbGFzdF9wYWNrZXRfdGltZRgDIAEoCRIRCglpc19yZWNlbnQYBCABKAgiYAoITXF0dEluZm8SEwoLaXNfdXBsaW5rZWQYASABKAgSEwoLaGFzX3BhY2tldHMYAiABKAgSKgoGdG9waWNzGAMgAygLMhoubWVzaGV4cGxvcmVyLnYxLk1xdHRUb3BpYyJXCg5HZXROb2RlUmVxdWVzdBIbCgpwdWJsaWNfa2V5GAEgASgJQge6SARyAhAKEh4KBWxpbWl0GAIgASgFQgq6SAcaBRjoBygBSACIAQFCCAoGX2xpbWl0IvABCg9HZXROb2RlUmVzcG9uc2USJwoEbm9kZRgBIAEoCzIZLm1lc2hleHBsb3Jlci52MS5Ob2RlSW5mbxIvCg5yZWNlbnRfYWR2ZXJ0cxgCIAMoCzIXLm1lc2hleHBsb3Jlci52MS5BZHZlcnQSOgoQbG9jYXRpb25faGlzdG9yeRgDIAMoCzIgLm1lc2hleHBsb3Jlci52MS5Mb2NhdGlvbkhpc3RvcnkSJwoEbXF0dBgEIAEoCzIZLm1lc2hleHBsb3Jlci52MS5NcXR0SW5mbxITCgZyZWdpb24YBSABKAlIAIgBAUIJCgdfcmVnaW9uIvoBCghOZWlnaGJvchISCgpwdWJsaWNfa2V5GAEgASgJEhEKCW5vZGVfbmFtZRgCIAEoCRIVCghsYXRpdHVkZRgDIAEoAUgAiAEBEhYKCWxvbmdpdHVkZRgEIAEoAUgBiAEBEhQKDGhhc19sb2NhdGlvbhgFIAEoBRITCgtpc19yZXBlYXRlchgGIAEoBRIUCgxpc19jaGF0X25vZGUYByABKAUSFgoOaXNfcm9vbV9zZXJ2ZXIYCCABKAUSEAoIaGFzX25hbWUYCSABKAUSEgoKZGlyZWN0aW9ucxgKIAMoCUILCglfbGF0aXR1ZGVCDAoKX2xvbmdpdHVkZSJlChdHZXROb2RlTmVpZ2hib3JzUmVxdWVzdBIbCgpwdWJsaWNfa2V5GAEgASgJQge6SARyAhAKEh8KCWxhc3Rfc2VlbhgCIAEoBUIHukgEGgIoAEgAiAEBQgwKCl9sYXN0X3NlZW4iSAoYR2V0Tm9kZU5laWdoYm9yc1Jlc3BvbnNlEiwKCW5laWdoYm9ycxgBIAMoCzIZLm1lc2hleHBsb3Jlci52MS5OZWlnaGJvciL1AQoLU2VhcmNoUXVlcnkSGwoFcXVlcnkYASABKAlCB7pIBHICGGRIAIgBARITCgZyZWdpb24YAiABKAlIAYgBARIfCglsYXN0X3NlZW4YAyABKAVCB7pIBBoCKABIAogBARIeCgVsaW1pdBgEIAEoBUIKukgHGgUYyAEoAUgDiAEBEhIKBWV4YWN0GAUgASgISASIAQESGAoLaXNfcmVwZWF0ZXIYBiABKAhIBYgBAUIICgZfcXVlcnlCCQoHX3JlZ2lvbkIMCgpfbGFzdF9zZWVuQggKBl9saW1pdEIICgZfZXhhY3RCDgoMX2lzX3JlcGVhdGVyIrECCgxTZWFyY2hSZXN1bHQSEgoKcHVibGljX2tleRgBIAEoCRIRCglub2RlX25hbWUYAiABKAkSFQoIbGF0aXR1ZGUYAyABKAFIAIgBARIWCglsb25naXR1ZGUYBCABKAFIAYgBARIUCgxoYXNfbG9jYXRpb24YBSABKAUSEwoLaXNfcmVwZWF0ZXIYBiABKAUSFAoMaXNfY2hhdF9ub2RlGAcgASgFEhYKDmlzX3Jvb21fc2VydmVyGAggASgFEhAKCGhhc19uYW1lGAkgASgFEhMKC2ZpcnN0X2hlYXJkGAogASgJEhEKCWxhc3Rfc2VlbhgLIAEoCRIOCgZicm9rZXIYDCABKAkSDQoFdG9waWMYDSABKAlCCwoJX2xhdGl0dWRlQgwKCl9sb25naXR1ZGUiQgoQU2VhcmNoUmVzdWx0TGlzdBIuCgdyZXN1bHRzGAEgAygLMh0ubWVzaGV4cGxvcmVyLnYxLlNlYXJjaFJlc3VsdCJOChJTZWFyY2hOb2Rlc1JlcXVlc3QSOAoHcXVlcmllcxgBIAMoCzIcLm1lc2hleHBsb3Jlci52MS5TZWFyY2hRdWVyeUIJukgGkgEDEPQDIkkKE1NlYXJjaE5vZGVzUmVzcG9uc2USMgoHcmVzdWx0cxgBIAMoCzIhLm1lc2hleHBsb3Jlci52MS5TZWFyY2hSZXN1bHRMaXN0Mp4CCgtOb2RlU2VydmljZRJMCgdHZXROb2RlEh8ubWVzaGV4cGxvcmVyLnYxLkdldE5vZGVSZXF1ZXN0GiAubWVzaGV4cGxvcmVyLnYxLkdldE5vZGVSZXNwb25zZRJnChBHZXROb2RlTmVpZ2hib3JzEigubWVzaGV4cGxvcmVyLnYxLkdldE5vZGVOZWlnaGJvcnNSZXF1ZXN0GikubWVzaGV4cGxvcmVyLnYxLkdldE5vZGVOZWlnaGJvcnNSZXNwb25zZRJYCgtTZWFyY2hOb2RlcxIjLm1lc2hleHBsb3Jlci52MS5TZWFyY2hOb2Rlc1JlcXVlc3QaJC5tZXNoZXhwbG9yZXIudjEuU2VhcmNoTm9kZXNSZXNwb25zZWIGcHJvdG8z", [file_buf_validate_validate]);
/**
* Basic node identity/capabilities from the latest advert (getMeshcoreNodeInfo).
*
* @generated from message meshexplorer.v1.NodeInfo
*/
export type NodeInfo = Message<"meshexplorer.v1.NodeInfo"> & {
/**
* @generated from field: string public_key = 1;
*/
publicKey: string;
/**
* @generated from field: string node_name = 2;
*/
nodeName: string;
/**
* @generated from field: optional double latitude = 3;
*/
latitude?: number | undefined;
/**
* @generated from field: optional double longitude = 4;
*/
longitude?: number | undefined;
/**
* @generated from field: int32 has_location = 5;
*/
hasLocation: number;
/**
* @generated from field: int32 is_repeater = 6;
*/
isRepeater: number;
/**
* @generated from field: int32 is_chat_node = 7;
*/
isChatNode: number;
/**
* @generated from field: int32 is_room_server = 8;
*/
isRoomServer: number;
/**
* @generated from field: int32 has_name = 9;
*/
hasName: number;
/**
* @generated from field: optional string broker = 10;
*/
broker?: string | undefined;
/**
* @generated from field: optional string topic = 11;
*/
topic?: string | undefined;
/**
* @generated from field: string first_seen = 12;
*/
firstSeen: string;
/**
* @generated from field: string last_seen = 13;
*/
lastSeen: string;
};
/**
* Describes the message meshexplorer.v1.NodeInfo.
* Use `create(NodeInfoSchema)` to create a new message.
*/
export const NodeInfoSchema: GenMessage<NodeInfo> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 0);
/**
* One (origin, path, origin_pubkey) tuple from an advert's reception paths.
*
* @generated from message meshexplorer.v1.OriginPathPubkeyTuple
*/
export type OriginPathPubkeyTuple = Message<"meshexplorer.v1.OriginPathPubkeyTuple"> & {
/**
* @generated from field: string origin = 1;
*/
origin: string;
/**
* @generated from field: string path = 2;
*/
path: string;
/**
* @generated from field: string origin_pubkey = 3;
*/
originPubkey: string;
};
/**
* Describes the message meshexplorer.v1.OriginPathPubkeyTuple.
* Use `create(OriginPathPubkeyTupleSchema)` to create a new message.
*/
export const OriginPathPubkeyTupleSchema: GenMessage<OriginPathPubkeyTuple> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 1);
/**
* An advert grouped by packet_hash, with all the paths it was heard over.
*
* @generated from message meshexplorer.v1.Advert
*/
export type Advert = Message<"meshexplorer.v1.Advert"> & {
/**
* @generated from field: string adv_timestamp = 1;
*/
advTimestamp: string;
/**
* @generated from field: repeated meshexplorer.v1.OriginPathPubkeyTuple origin_path_pubkey_tuples = 2;
*/
originPathPubkeyTuples: OriginPathPubkeyTuple[];
/**
* @generated from field: int32 advert_count = 3;
*/
advertCount: number;
/**
* @generated from field: string earliest_timestamp = 4;
*/
earliestTimestamp: string;
/**
* @generated from field: string latest_timestamp = 5;
*/
latestTimestamp: string;
/**
* @generated from field: optional double latitude = 6;
*/
latitude?: number | undefined;
/**
* @generated from field: optional double longitude = 7;
*/
longitude?: number | undefined;
/**
* @generated from field: int32 is_repeater = 8;
*/
isRepeater: number;
/**
* @generated from field: int32 is_chat_node = 9;
*/
isChatNode: number;
/**
* @generated from field: int32 is_room_server = 10;
*/
isRoomServer: number;
/**
* @generated from field: int32 has_location = 11;
*/
hasLocation: number;
/**
* @generated from field: string packet_hash = 12;
*/
packetHash: string;
};
/**
* Describes the message meshexplorer.v1.Advert.
* Use `create(AdvertSchema)` to create a new message.
*/
export const AdvertSchema: GenMessage<Advert> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 2);
/**
* @generated from message meshexplorer.v1.LocationHistory
*/
export type LocationHistory = Message<"meshexplorer.v1.LocationHistory"> & {
/**
* @generated from field: string mesh_timestamp = 1;
*/
meshTimestamp: string;
/**
* @generated from field: double latitude = 2;
*/
latitude: number;
/**
* @generated from field: double longitude = 3;
*/
longitude: number;
};
/**
* Describes the message meshexplorer.v1.LocationHistory.
* Use `create(LocationHistorySchema)` to create a new message.
*/
export const LocationHistorySchema: GenMessage<LocationHistory> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 3);
/**
* @generated from message meshexplorer.v1.MqttTopic
*/
export type MqttTopic = Message<"meshexplorer.v1.MqttTopic"> & {
/**
* @generated from field: string topic = 1;
*/
topic: string;
/**
* @generated from field: string broker = 2;
*/
broker: string;
/**
* @generated from field: string last_packet_time = 3;
*/
lastPacketTime: string;
/**
* @generated from field: bool is_recent = 4;
*/
isRecent: boolean;
};
/**
* Describes the message meshexplorer.v1.MqttTopic.
* Use `create(MqttTopicSchema)` to create a new message.
*/
export const MqttTopicSchema: GenMessage<MqttTopic> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 4);
/**
* @generated from message meshexplorer.v1.MqttInfo
*/
export type MqttInfo = Message<"meshexplorer.v1.MqttInfo"> & {
/**
* @generated from field: bool is_uplinked = 1;
*/
isUplinked: boolean;
/**
* @generated from field: bool has_packets = 2;
*/
hasPackets: boolean;
/**
* @generated from field: repeated meshexplorer.v1.MqttTopic topics = 3;
*/
topics: MqttTopic[];
};
/**
* Describes the message meshexplorer.v1.MqttInfo.
* Use `create(MqttInfoSchema)` to create a new message.
*/
export const MqttInfoSchema: GenMessage<MqttInfo> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 5);
/**
* @generated from message meshexplorer.v1.GetNodeRequest
*/
export type GetNodeRequest = Message<"meshexplorer.v1.GetNodeRequest"> & {
/**
* @generated from field: string public_key = 1;
*/
publicKey: string;
/**
* Max number of recent adverts to return (default 50).
*
* @generated from field: optional int32 limit = 2;
*/
limit?: number | undefined;
};
/**
* Describes the message meshexplorer.v1.GetNodeRequest.
* Use `create(GetNodeRequestSchema)` to create a new message.
*/
export const GetNodeRequestSchema: GenMessage<GetNodeRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 6);
/**
* @generated from message meshexplorer.v1.GetNodeResponse
*/
export type GetNodeResponse = Message<"meshexplorer.v1.GetNodeResponse"> & {
/**
* @generated from field: meshexplorer.v1.NodeInfo node = 1;
*/
node?: NodeInfo | undefined;
/**
* @generated from field: repeated meshexplorer.v1.Advert recent_adverts = 2;
*/
recentAdverts: Advert[];
/**
* @generated from field: repeated meshexplorer.v1.LocationHistory location_history = 3;
*/
locationHistory: LocationHistory[];
/**
* @generated from field: meshexplorer.v1.MqttInfo mqtt = 4;
*/
mqtt?: MqttInfo | undefined;
/**
* @generated from field: optional string region = 5;
*/
region?: string | undefined;
};
/**
* Describes the message meshexplorer.v1.GetNodeResponse.
* Use `create(GetNodeResponseSchema)` to create a new message.
*/
export const GetNodeResponseSchema: GenMessage<GetNodeResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 7);
/**
* Direct neighbor of a node (meshcore_node_direct_neighbors).
*
* @generated from message meshexplorer.v1.Neighbor
*/
export type Neighbor = Message<"meshexplorer.v1.Neighbor"> & {
/**
* @generated from field: string public_key = 1;
*/
publicKey: string;
/**
* @generated from field: string node_name = 2;
*/
nodeName: string;
/**
* @generated from field: optional double latitude = 3;
*/
latitude?: number | undefined;
/**
* @generated from field: optional double longitude = 4;
*/
longitude?: number | undefined;
/**
* @generated from field: int32 has_location = 5;
*/
hasLocation: number;
/**
* @generated from field: int32 is_repeater = 6;
*/
isRepeater: number;
/**
* @generated from field: int32 is_chat_node = 7;
*/
isChatNode: number;
/**
* @generated from field: int32 is_room_server = 8;
*/
isRoomServer: number;
/**
* @generated from field: int32 has_name = 9;
*/
hasName: number;
/**
* @generated from field: repeated string directions = 10;
*/
directions: string[];
};
/**
* Describes the message meshexplorer.v1.Neighbor.
* Use `create(NeighborSchema)` to create a new message.
*/
export const NeighborSchema: GenMessage<Neighbor> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 8);
/**
* @generated from message meshexplorer.v1.GetNodeNeighborsRequest
*/
export type GetNodeNeighborsRequest = Message<"meshexplorer.v1.GetNodeNeighborsRequest"> & {
/**
* @generated from field: string public_key = 1;
*/
publicKey: string;
/**
* @generated from field: optional int32 last_seen = 2;
*/
lastSeen?: number | undefined;
};
/**
* Describes the message meshexplorer.v1.GetNodeNeighborsRequest.
* Use `create(GetNodeNeighborsRequestSchema)` to create a new message.
*/
export const GetNodeNeighborsRequestSchema: GenMessage<GetNodeNeighborsRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 9);
/**
* @generated from message meshexplorer.v1.GetNodeNeighborsResponse
*/
export type GetNodeNeighborsResponse = Message<"meshexplorer.v1.GetNodeNeighborsResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.Neighbor neighbors = 1;
*/
neighbors: Neighbor[];
};
/**
* Describes the message meshexplorer.v1.GetNodeNeighborsResponse.
* Use `create(GetNodeNeighborsResponseSchema)` to create a new message.
*/
export const GetNodeNeighborsResponseSchema: GenMessage<GetNodeNeighborsResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 10);
/**
* One search request within a (possibly batched) SearchNodes call.
*
* @generated from message meshexplorer.v1.SearchQuery
*/
export type SearchQuery = Message<"meshexplorer.v1.SearchQuery"> & {
/**
* @generated from field: optional string query = 1;
*/
query?: string | undefined;
/**
* @generated from field: optional string region = 2;
*/
region?: string | undefined;
/**
* @generated from field: optional int32 last_seen = 3;
*/
lastSeen?: number | undefined;
/**
* @generated from field: optional int32 limit = 4;
*/
limit?: number | undefined;
/**
* @generated from field: optional bool exact = 5;
*/
exact?: boolean | undefined;
/**
* @generated from field: optional bool is_repeater = 6;
*/
isRepeater?: boolean | undefined;
};
/**
* Describes the message meshexplorer.v1.SearchQuery.
* Use `create(SearchQuerySchema)` to create a new message.
*/
export const SearchQuerySchema: GenMessage<SearchQuery> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 11);
/**
* @generated from message meshexplorer.v1.SearchResult
*/
export type SearchResult = Message<"meshexplorer.v1.SearchResult"> & {
/**
* @generated from field: string public_key = 1;
*/
publicKey: string;
/**
* @generated from field: string node_name = 2;
*/
nodeName: string;
/**
* @generated from field: optional double latitude = 3;
*/
latitude?: number | undefined;
/**
* @generated from field: optional double longitude = 4;
*/
longitude?: number | undefined;
/**
* @generated from field: int32 has_location = 5;
*/
hasLocation: number;
/**
* @generated from field: int32 is_repeater = 6;
*/
isRepeater: number;
/**
* @generated from field: int32 is_chat_node = 7;
*/
isChatNode: number;
/**
* @generated from field: int32 is_room_server = 8;
*/
isRoomServer: number;
/**
* @generated from field: int32 has_name = 9;
*/
hasName: number;
/**
* @generated from field: string first_heard = 10;
*/
firstHeard: string;
/**
* @generated from field: string last_seen = 11;
*/
lastSeen: string;
/**
* @generated from field: string broker = 12;
*/
broker: string;
/**
* @generated from field: string topic = 13;
*/
topic: string;
};
/**
* Describes the message meshexplorer.v1.SearchResult.
* Use `create(SearchResultSchema)` to create a new message.
*/
export const SearchResultSchema: GenMessage<SearchResult> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 12);
/**
* Results for a single query in the batch (preserves per-query grouping).
*
* @generated from message meshexplorer.v1.SearchResultList
*/
export type SearchResultList = Message<"meshexplorer.v1.SearchResultList"> & {
/**
* @generated from field: repeated meshexplorer.v1.SearchResult results = 1;
*/
results: SearchResult[];
};
/**
* Describes the message meshexplorer.v1.SearchResultList.
* Use `create(SearchResultListSchema)` to create a new message.
*/
export const SearchResultListSchema: GenMessage<SearchResultList> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 13);
/**
* @generated from message meshexplorer.v1.SearchNodesRequest
*/
export type SearchNodesRequest = Message<"meshexplorer.v1.SearchNodesRequest"> & {
/**
* @generated from field: repeated meshexplorer.v1.SearchQuery queries = 1;
*/
queries: SearchQuery[];
};
/**
* Describes the message meshexplorer.v1.SearchNodesRequest.
* Use `create(SearchNodesRequestSchema)` to create a new message.
*/
export const SearchNodesRequestSchema: GenMessage<SearchNodesRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 14);
/**
* @generated from message meshexplorer.v1.SearchNodesResponse
*/
export type SearchNodesResponse = Message<"meshexplorer.v1.SearchNodesResponse"> & {
/**
* One entry per input query, in the same order.
*
* @generated from field: repeated meshexplorer.v1.SearchResultList results = 1;
*/
results: SearchResultList[];
};
/**
* Describes the message meshexplorer.v1.SearchNodesResponse.
* Use `create(SearchNodesResponseSchema)` to create a new message.
*/
export const SearchNodesResponseSchema: GenMessage<SearchNodesResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_node, 15);
/**
* @generated from service meshexplorer.v1.NodeService
*/
export const NodeService: GenService<{
/**
* @generated from rpc meshexplorer.v1.NodeService.GetNode
*/
getNode: {
methodKind: "unary";
input: typeof GetNodeRequestSchema;
output: typeof GetNodeResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.NodeService.GetNodeNeighbors
*/
getNodeNeighbors: {
methodKind: "unary";
input: typeof GetNodeNeighborsRequestSchema;
output: typeof GetNodeNeighborsResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.NodeService.SearchNodes
*/
searchNodes: {
methodKind: "unary";
input: typeof SearchNodesRequestSchema;
output: typeof SearchNodesResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_node, 0);
@@ -0,0 +1,154 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/packets.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../buf/validate/validate_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/packets.proto.
*/
export const file_meshexplorer_v1_packets: GenFile = /*@__PURE__*/
fileDesc("Ch1tZXNoZXhwbG9yZXIvdjEvcGFja2V0cy5wcm90bxIPbWVzaGV4cGxvcmVyLnYxIvMBCgZQYWNrZXQSGAoQaW5nZXN0X3RpbWVzdGFtcBgBIAEoCRIWCg5tZXNoX3RpbWVzdGFtcBgCIAEoCRIOCgZicm9rZXIYAyABKAkSDQoFdG9waWMYBCABKAkSDgoGcGFja2V0GAUgASgJEhAKCHBhdGhfbGVuGAYgASgFEgwKBHBhdGgYByABKAkSEgoKcm91dGVfdHlwZRgIIAEoBRIUCgxwYXlsb2FkX3R5cGUYCSABKAUSFwoPcGF5bG9hZF92ZXJzaW9uGAogASgFEg4KBmhlYWRlchgLIAEoBRIVCg1vcmlnaW5fcHVia2V5GAwgASgJIs8CChRTdHJlYW1QYWNrZXRzUmVxdWVzdBITCgZyZWdpb24YASABKAlIAIgBARIkCgxwYXlsb2FkX3R5cGUYAiABKAVCCbpIBhoEGA8oAEgBiAEBEiIKCnJvdXRlX3R5cGUYAyABKAVCCbpIBhoEGAMoAEgCiAEBEjEKDW9yaWdpbl9wdWJrZXkYBCABKAlCFbpIEnIQMg5eWzAtOUEtRmEtZl0rJEgDiAEBEiYKDXBvbGxfaW50ZXJ2YWwYBSABKAVCCrpIBxoFGJBOKGRIBIgBARIhCghtYXhfcm93cxgGIAEoBUIKukgHGgUYkE4oCkgFiAEBQgkKB19yZWdpb25CDwoNX3BheWxvYWRfdHlwZUINCgtfcm91dGVfdHlwZUIQCg5fb3JpZ2luX3B1YmtleUIQCg5fcG9sbF9pbnRlcnZhbEILCglfbWF4X3Jvd3MyYwoOUGFja2V0c1NlcnZpY2USUQoNU3RyZWFtUGFja2V0cxIlLm1lc2hleHBsb3Jlci52MS5TdHJlYW1QYWNrZXRzUmVxdWVzdBoXLm1lc2hleHBsb3Jlci52MS5QYWNrZXQwAWIGcHJvdG8z", [file_buf_validate_validate]);
/**
* A raw mesh packet (meshcore_packets), hex fields kept as hex strings.
*
* @generated from message meshexplorer.v1.Packet
*/
export type Packet = Message<"meshexplorer.v1.Packet"> & {
/**
* @generated from field: string ingest_timestamp = 1;
*/
ingestTimestamp: string;
/**
* @generated from field: string mesh_timestamp = 2;
*/
meshTimestamp: string;
/**
* @generated from field: string broker = 3;
*/
broker: string;
/**
* @generated from field: string topic = 4;
*/
topic: string;
/**
* @generated from field: string packet = 5;
*/
packet: string;
/**
* @generated from field: int32 path_len = 6;
*/
pathLen: number;
/**
* @generated from field: string path = 7;
*/
path: string;
/**
* @generated from field: int32 route_type = 8;
*/
routeType: number;
/**
* @generated from field: int32 payload_type = 9;
*/
payloadType: number;
/**
* @generated from field: int32 payload_version = 10;
*/
payloadVersion: number;
/**
* @generated from field: int32 header = 11;
*/
header: number;
/**
* @generated from field: string origin_pubkey = 12;
*/
originPubkey: string;
};
/**
* Describes the message meshexplorer.v1.Packet.
* Use `create(PacketSchema)` to create a new message.
*/
export const PacketSchema: GenMessage<Packet> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_packets, 0);
/**
* @generated from message meshexplorer.v1.StreamPacketsRequest
*/
export type StreamPacketsRequest = Message<"meshexplorer.v1.StreamPacketsRequest"> & {
/**
* @generated from field: optional string region = 1;
*/
region?: string | undefined;
/**
* Payload type filter (0..15).
*
* @generated from field: optional int32 payload_type = 2;
*/
payloadType?: number | undefined;
/**
* Route type filter (0..3).
*
* @generated from field: optional int32 route_type = 3;
*/
routeType?: number | undefined;
/**
* @generated from field: optional string origin_pubkey = 4;
*/
originPubkey?: string | undefined;
/**
* Poll interval in ms (clamped 100..10000, default 500).
*
* @generated from field: optional int32 poll_interval = 5;
*/
pollInterval?: number | undefined;
/**
* Max rows per poll (clamped 10..10000, default 10).
*
* @generated from field: optional int32 max_rows = 6;
*/
maxRows?: number | undefined;
};
/**
* Describes the message meshexplorer.v1.StreamPacketsRequest.
* Use `create(StreamPacketsRequestSchema)` to create a new message.
*/
export const StreamPacketsRequestSchema: GenMessage<StreamPacketsRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_packets, 1);
/**
* @generated from service meshexplorer.v1.PacketsService
*/
export const PacketsService: GenService<{
/**
* @generated from rpc meshexplorer.v1.PacketsService.StreamPackets
*/
streamPackets: {
methodKind: "server_streaming";
input: typeof StreamPacketsRequestSchema;
output: typeof PacketSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_packets, 0);
@@ -0,0 +1,224 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=none"
// @generated from file meshexplorer/v1/stats.proto (package meshexplorer.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file meshexplorer/v1/stats.proto.
*/
export const file_meshexplorer_v1_stats: GenFile = /*@__PURE__*/
fileDesc("ChttZXNoZXhwbG9yZXIvdjEvc3RhdHMucHJvdG8SD21lc2hleHBsb3Jlci52MSIuCgxTdGF0c1JlcXVlc3QSEwoGcmVnaW9uGAEgASgJSACIAQFCCQoHX3JlZ2lvbiIsChVHZXRUb3RhbE5vZGVzUmVzcG9uc2USEwoLdG90YWxfbm9kZXMYASABKAUipgEKEE5vZGVzT3ZlclRpbWVSb3cSCwoDZGF5GAEgASgJEh8KF2N1bXVsYXRpdmVfdW5pcXVlX25vZGVzGAIgASgFEhsKE25vZGVzX3dpdGhfbG9jYXRpb24YAyABKAUSHgoWbm9kZXNfd2l0aG91dF9sb2NhdGlvbhgEIAEoBRIRCglyZXBlYXRlcnMYBSABKAUSFAoMcm9vbV9zZXJ2ZXJzGAYgASgFIksKGEdldE5vZGVzT3ZlclRpbWVSZXNwb25zZRIvCgRkYXRhGAEgAygLMiEubWVzaGV4cGxvcmVyLnYxLk5vZGVzT3ZlclRpbWVSb3ciQAoRUG9wdWxhckNoYW5uZWxSb3cSFAoMY2hhbm5lbF9oYXNoGAEgASgJEhUKDW1lc3NhZ2VfY291bnQYAiABKAUiTgoaR2V0UG9wdWxhckNoYW5uZWxzUmVzcG9uc2USMAoEZGF0YRgBIAMoCzIiLm1lc2hleHBsb3Jlci52MS5Qb3B1bGFyQ2hhbm5lbFJvdyI3ChFSZXBlYXRlclByZWZpeFJvdxIOCgZwcmVmaXgYASABKAkSEgoKbm9kZV9uYW1lcxgCIAMoCSJPChtHZXRSZXBlYXRlclByZWZpeGVzUmVzcG9uc2USMAoEZGF0YRgBIAMoCzIiLm1lc2hleHBsb3Jlci52MS5SZXBlYXRlclByZWZpeFJvdzKKAwoMU3RhdHNTZXJ2aWNlElYKDUdldFRvdGFsTm9kZXMSHS5tZXNoZXhwbG9yZXIudjEuU3RhdHNSZXF1ZXN0GiYubWVzaGV4cGxvcmVyLnYxLkdldFRvdGFsTm9kZXNSZXNwb25zZRJcChBHZXROb2Rlc092ZXJUaW1lEh0ubWVzaGV4cGxvcmVyLnYxLlN0YXRzUmVxdWVzdBopLm1lc2hleHBsb3Jlci52MS5HZXROb2Rlc092ZXJUaW1lUmVzcG9uc2USYAoSR2V0UG9wdWxhckNoYW5uZWxzEh0ubWVzaGV4cGxvcmVyLnYxLlN0YXRzUmVxdWVzdBorLm1lc2hleHBsb3Jlci52MS5HZXRQb3B1bGFyQ2hhbm5lbHNSZXNwb25zZRJiChNHZXRSZXBlYXRlclByZWZpeGVzEh0ubWVzaGV4cGxvcmVyLnYxLlN0YXRzUmVxdWVzdBosLm1lc2hleHBsb3Jlci52MS5HZXRSZXBlYXRlclByZWZpeGVzUmVzcG9uc2ViBnByb3RvMw");
/**
* @generated from message meshexplorer.v1.StatsRequest
*/
export type StatsRequest = Message<"meshexplorer.v1.StatsRequest"> & {
/**
* @generated from field: optional string region = 1;
*/
region?: string | undefined;
};
/**
* Describes the message meshexplorer.v1.StatsRequest.
* Use `create(StatsRequestSchema)` to create a new message.
*/
export const StatsRequestSchema: GenMessage<StatsRequest> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 0);
/**
* @generated from message meshexplorer.v1.GetTotalNodesResponse
*/
export type GetTotalNodesResponse = Message<"meshexplorer.v1.GetTotalNodesResponse"> & {
/**
* @generated from field: int32 total_nodes = 1;
*/
totalNodes: number;
};
/**
* Describes the message meshexplorer.v1.GetTotalNodesResponse.
* Use `create(GetTotalNodesResponseSchema)` to create a new message.
*/
export const GetTotalNodesResponseSchema: GenMessage<GetTotalNodesResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 1);
/**
* @generated from message meshexplorer.v1.NodesOverTimeRow
*/
export type NodesOverTimeRow = Message<"meshexplorer.v1.NodesOverTimeRow"> & {
/**
* @generated from field: string day = 1;
*/
day: string;
/**
* @generated from field: int32 cumulative_unique_nodes = 2;
*/
cumulativeUniqueNodes: number;
/**
* @generated from field: int32 nodes_with_location = 3;
*/
nodesWithLocation: number;
/**
* @generated from field: int32 nodes_without_location = 4;
*/
nodesWithoutLocation: number;
/**
* @generated from field: int32 repeaters = 5;
*/
repeaters: number;
/**
* @generated from field: int32 room_servers = 6;
*/
roomServers: number;
};
/**
* Describes the message meshexplorer.v1.NodesOverTimeRow.
* Use `create(NodesOverTimeRowSchema)` to create a new message.
*/
export const NodesOverTimeRowSchema: GenMessage<NodesOverTimeRow> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 2);
/**
* @generated from message meshexplorer.v1.GetNodesOverTimeResponse
*/
export type GetNodesOverTimeResponse = Message<"meshexplorer.v1.GetNodesOverTimeResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.NodesOverTimeRow data = 1;
*/
data: NodesOverTimeRow[];
};
/**
* Describes the message meshexplorer.v1.GetNodesOverTimeResponse.
* Use `create(GetNodesOverTimeResponseSchema)` to create a new message.
*/
export const GetNodesOverTimeResponseSchema: GenMessage<GetNodesOverTimeResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 3);
/**
* @generated from message meshexplorer.v1.PopularChannelRow
*/
export type PopularChannelRow = Message<"meshexplorer.v1.PopularChannelRow"> & {
/**
* @generated from field: string channel_hash = 1;
*/
channelHash: string;
/**
* @generated from field: int32 message_count = 2;
*/
messageCount: number;
};
/**
* Describes the message meshexplorer.v1.PopularChannelRow.
* Use `create(PopularChannelRowSchema)` to create a new message.
*/
export const PopularChannelRowSchema: GenMessage<PopularChannelRow> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 4);
/**
* @generated from message meshexplorer.v1.GetPopularChannelsResponse
*/
export type GetPopularChannelsResponse = Message<"meshexplorer.v1.GetPopularChannelsResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.PopularChannelRow data = 1;
*/
data: PopularChannelRow[];
};
/**
* Describes the message meshexplorer.v1.GetPopularChannelsResponse.
* Use `create(GetPopularChannelsResponseSchema)` to create a new message.
*/
export const GetPopularChannelsResponseSchema: GenMessage<GetPopularChannelsResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 5);
/**
* @generated from message meshexplorer.v1.RepeaterPrefixRow
*/
export type RepeaterPrefixRow = Message<"meshexplorer.v1.RepeaterPrefixRow"> & {
/**
* @generated from field: string prefix = 1;
*/
prefix: string;
/**
* @generated from field: repeated string node_names = 2;
*/
nodeNames: string[];
};
/**
* Describes the message meshexplorer.v1.RepeaterPrefixRow.
* Use `create(RepeaterPrefixRowSchema)` to create a new message.
*/
export const RepeaterPrefixRowSchema: GenMessage<RepeaterPrefixRow> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 6);
/**
* @generated from message meshexplorer.v1.GetRepeaterPrefixesResponse
*/
export type GetRepeaterPrefixesResponse = Message<"meshexplorer.v1.GetRepeaterPrefixesResponse"> & {
/**
* @generated from field: repeated meshexplorer.v1.RepeaterPrefixRow data = 1;
*/
data: RepeaterPrefixRow[];
};
/**
* Describes the message meshexplorer.v1.GetRepeaterPrefixesResponse.
* Use `create(GetRepeaterPrefixesResponseSchema)` to create a new message.
*/
export const GetRepeaterPrefixesResponseSchema: GenMessage<GetRepeaterPrefixesResponse> = /*@__PURE__*/
messageDesc(file_meshexplorer_v1_stats, 7);
/**
* @generated from service meshexplorer.v1.StatsService
*/
export const StatsService: GenService<{
/**
* @generated from rpc meshexplorer.v1.StatsService.GetTotalNodes
*/
getTotalNodes: {
methodKind: "unary";
input: typeof StatsRequestSchema;
output: typeof GetTotalNodesResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.StatsService.GetNodesOverTime
*/
getNodesOverTime: {
methodKind: "unary";
input: typeof StatsRequestSchema;
output: typeof GetNodesOverTimeResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.StatsService.GetPopularChannels
*/
getPopularChannels: {
methodKind: "unary";
input: typeof StatsRequestSchema;
output: typeof GetPopularChannelsResponseSchema;
},
/**
* @generated from rpc meshexplorer.v1.StatsService.GetRepeaterPrefixes
*/
getRepeaterPrefixes: {
methodKind: "unary";
input: typeof StatsRequestSchema;
output: typeof GetRepeaterPrefixesResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_meshexplorer_v1_stats, 0);
+41 -50
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { buildApiUrl } from '@/lib/api';
import { useQuery } from '@connectrpc/connect-query';
import { NeighborsService } from '@/gen/meshexplorer/v1/neighbors_pb';
export interface AllNeighborsConnection {
source_node: string;
@@ -27,55 +27,46 @@ interface UseAllNeighborsParams {
enabled?: boolean;
}
export function useAllNeighbors({
minLat,
maxLat,
minLng,
maxLng,
nodeTypes,
lastSeen,
export function useAllNeighbors({
minLat,
maxLat,
minLng,
maxLng,
nodeTypes,
lastSeen,
region,
enabled = true
enabled = true,
}: UseAllNeighborsParams) {
return useQuery({
queryKey: ['allNeighbors', minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen, region],
queryFn: async (): Promise<AllNeighborsConnection[]> => {
const params = new URLSearchParams();
if (minLat !== null && minLat !== undefined) {
params.append('minLat', minLat.toString());
}
if (maxLat !== null && maxLat !== undefined) {
params.append('maxLat', maxLat.toString());
}
if (minLng !== null && minLng !== undefined) {
params.append('minLng', minLng.toString());
}
if (maxLng !== null && maxLng !== undefined) {
params.append('maxLng', maxLng.toString());
}
if (nodeTypes && nodeTypes.length > 0) {
nodeTypes.forEach(type => params.append('nodeTypes', type));
}
if (lastSeen !== null && lastSeen !== undefined) {
params.append('lastSeen', lastSeen.toString());
}
if (region) {
params.append('region', region);
}
const url = `/api/neighbors/all${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(buildApiUrl(url));
if (!response.ok) {
throw new Error(`Failed to fetch all neighbors: ${response.statusText}`);
}
return response.json();
return useQuery(
NeighborsService.method.getAllNeighbors,
{
minLat: minLat ?? undefined,
maxLat: maxLat ?? undefined,
minLng: minLng ?? undefined,
maxLng: maxLng ?? undefined,
nodeTypes: nodeTypes ?? [],
lastSeen: lastSeen ?? undefined,
region,
},
enabled: enabled,
staleTime: 5 * 60 * 1000, // 5 minutes (shorter than individual neighbors since this is more expensive)
gcTime: 15 * 60 * 1000, // 15 minutes
});
{
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
},
);
}
+122 -117
View File
@@ -1,8 +1,10 @@
"use client";
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { buildApiUrl } from '@/lib/api';
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';
interface ChatMessagesParams {
@@ -20,6 +22,63 @@ 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.
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);
let merged: ChatMessage[];
if (existingIndex !== -1) {
merged = [...all];
merged[existingIndex] = newMessage;
} else {
merged = [newMessage, ...all];
}
merged.sort(
(a, b) => new Date(b.ingest_timestamp).getTime() - new Date(a.ingest_timestamp).getTime(),
);
const pages = [];
for (let i = 0; i < merged.length; i += PAGE_SIZE) {
const pageIndex = Math.floor(i / PAGE_SIZE);
pages.push({
...(oldData.pages[pageIndex] || { hasMore: false }),
messages: merged.slice(i, i + PAGE_SIZE),
});
}
return { ...oldData, pages };
}
export function useChatMessages({
channelId,
region,
@@ -29,12 +88,12 @@ export function useChatMessages({
const queryClient = useQueryClient();
// Build base query key
const baseQueryKey = useMemo(() =>
['chat-messages', channelId, region] as const,
[channelId, region]
const baseQueryKey = useMemo(
() => ['chat-messages', channelId, region] as const,
[channelId, region],
);
// Main infinite query for loading messages with pagination
// Infinite query loads message history (older pages) via the unary GetChat RPC.
const messagesQuery = useInfiniteQuery({
queryKey: baseQueryKey,
queryFn: async ({ pageParam, signal }): Promise<ChatMessagesPage> => {
@@ -42,28 +101,23 @@ export function useChatMessages({
throw new Error('Region is required');
}
let url = `/api/chat?limit=${PAGE_SIZE}&region=${encodeURIComponent(region)}`;
if (channelId) {
url += `&channel_id=${channelId}`;
}
if (pageParam) {
url += `&before=${encodeURIComponent(pageParam)}`;
}
const res = await chatClient.getChat(
{
limit: PAGE_SIZE,
region,
channelId: channelId || undefined,
before: pageParam || undefined,
},
{ signal },
);
const messages = res.messages.map(toChatMessage);
const response = await fetch(buildApiUrl(url), { signal });
if (!response.ok) {
throw new Error(`Failed to fetch chat messages: ${response.statusText}`);
}
const data = await response.json();
const messages = Array.isArray(data) ? data : [];
return {
messages,
hasMore: messages.length === PAGE_SIZE,
oldestTimestamp: messages.length > 0 ? messages[messages.length - 1].ingest_timestamp : undefined,
oldestTimestamp:
messages.length > 0 ? messages[messages.length - 1].ingest_timestamp : undefined,
};
},
getNextPageParam: (lastPage) => {
@@ -76,106 +130,57 @@ export function useChatMessages({
retry: 1,
});
// Auto-refresh query to get newer messages
const latestTimestamp = messagesQuery.data?.pages[0]?.messages[0]?.ingest_timestamp;
const autoRefreshQuery = useQuery({
queryKey: [...baseQueryKey, 'auto-refresh', latestTimestamp],
queryFn: async ({ signal }): Promise<ChatMessage[]> => {
if (!region || !latestTimestamp) {
return [];
}
let url = `/api/chat?limit=${PAGE_SIZE}&region=${encodeURIComponent(region)}`;
if (channelId) {
url += `&channel_id=${channelId}`;
}
url += `&after=${encodeURIComponent(latestTimestamp)}`;
const response = await fetch(buildApiUrl(url), { signal });
if (!response.ok) {
throw new Error(`Failed to fetch new chat messages: ${response.statusText}`);
}
const data = await response.json();
return Array.isArray(data) ? data : [];
},
enabled: enabled && autoRefreshEnabled && !!region && !!latestTimestamp,
refetchInterval: 5000, // 5 seconds
staleTime: 0, // Always fresh for auto-refresh
retry: 1,
});
// When auto-refresh finds new messages, update the main query
// Live updates: subscribe to the StreamChat server-streaming RPC and merge new
// messages into the cache as they arrive (replaces the old 5s polling query).
useEffect(() => {
if (autoRefreshQuery.data && autoRefreshQuery.data.length > 0) {
queryClient.setQueryData(baseQueryKey, (oldData: any) => {
if (!oldData?.pages?.[0]) return oldData;
const newMessages = autoRefreshQuery.data;
// Get all existing messages from all pages
const allExistingMessages = oldData.pages.flatMap((page: any) => page.messages);
// Process new messages: replace duplicates and collect truly new ones
const trulyNewMessages: ChatMessage[] = [];
const updatedExistingMessages = [...allExistingMessages];
for (const newMessage of newMessages) {
const existingIndex = updatedExistingMessages.findIndex(
(msg: ChatMessage) => msg.message_id === newMessage.message_id
);
if (existingIndex !== -1) {
// Replace existing message with the new one
updatedExistingMessages[existingIndex] = newMessage;
} else {
// This is a truly new message
trulyNewMessages.push(newMessage);
}
}
// Combine truly new messages with updated existing messages
// Sort by ingest_timestamp to maintain order
const allMessages = [...trulyNewMessages, ...updatedExistingMessages]
.sort((a, b) => new Date(b.ingest_timestamp).getTime() - new Date(a.ingest_timestamp).getTime());
// Redistribute messages back into pages
const updatedPages = [];
let currentPageMessages = [];
for (let i = 0; i < allMessages.length; i++) {
currentPageMessages.push(allMessages[i]);
if (currentPageMessages.length === PAGE_SIZE || i === allMessages.length - 1) {
updatedPages.push({
...oldData.pages[Math.floor(i / PAGE_SIZE)] || { hasMore: false },
messages: currentPageMessages,
});
currentPageMessages = [];
}
}
return {
...oldData,
pages: updatedPages,
};
});
if (!enabled || !autoRefreshEnabled || !region) {
return;
}
}, [autoRefreshQuery.data, queryClient, baseQueryKey]);
const controller = new AbortController();
let cancelled = false;
(async () => {
try {
const stream = chatClient.streamChat(
{
channelId: channelId || undefined,
region,
// History is loaded by GetChat; only stream messages from now on.
skipInitialMessages: true,
},
{ signal: controller.signal },
);
for await (const genMsg of stream) {
if (cancelled) break;
const msg = toChatMessage(genMsg);
queryClient.setQueryData(baseQueryKey, (oldData: any) =>
mergeStreamedMessage(oldData, msg),
);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof ConnectError && err.code === Code.Canceled) return;
// A dropped stream shouldn't crash the chat UI; history is still usable.
console.warn('Chat stream error:', err);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [enabled, autoRefreshEnabled, region, channelId, queryClient, baseQueryKey]);
// Flatten all messages from all pages
const allMessages = messagesQuery.data?.pages.flatMap(page => page.messages) ?? [];
// Check if there are more pages to load
const hasNextPage = messagesQuery.hasNextPage;
const allMessages = messagesQuery.data?.pages.flatMap((page) => page.messages) ?? [];
return {
messages: allMessages,
loading: messagesQuery.isLoading,
error: messagesQuery.error || autoRefreshQuery.error,
hasMore: hasNextPage,
error: messagesQuery.error,
hasMore: messagesQuery.hasNextPage,
loadMore: messagesQuery.fetchNextPage,
isLoadingMore: messagesQuery.isFetchingNextPage,
refresh: () => {
+38 -17
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueries } from '@tanstack/react-query';
import { create, windowScheduler, indexedResolver } from '@yornaath/batshit';
import { buildApiUrl } from '@/lib/api';
import { nodeClient } from '@/lib/connect/client';
import type { SearchResult } from '@/gen/meshexplorer/v1/node_pb';
import { useMemo } from 'react';
export interface MeshcoreSearchResult {
@@ -19,6 +20,25 @@ export interface MeshcoreSearchResult {
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[];
total: number;
@@ -48,26 +68,27 @@ const searchBatcher = create({
// Create AbortController for this batch
const abortController = new AbortController();
// Store the abort controller so individual queries can cancel the batch
(searchBatcher as any)._currentAbortController = abortController;
const response = await fetch(buildApiUrl('/api/meshcore/search'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queries: normalizedQueries }),
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Failed to execute batch search: ${response.statusText}`);
}
const batchResponse = await response.json();
// Return results with batch context for resolver
const response = await nodeClient.searchNodes(
{
queries: normalizedQueries.map((q) => ({
query: q.query,
region: q.region,
lastSeen: q.lastSeen,
limit: q.limit,
exact: q.exact,
isRepeater: q.is_repeater,
})),
},
{ signal: abortController.signal },
);
// Return results with batch context for resolver (array-of-arrays, one per query)
return {
results: batchResponse.results || [],
results: response.results.map((list) => list.results.map(toSearchResult)),
queries: queries
};
},
+26 -23
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { buildApiUrl } from '@/lib/api';
import { useQuery } from '@connectrpc/connect-query';
import { NodeService } from '@/gen/meshexplorer/v1/node_pb';
export interface Neighbor {
public_key: string;
@@ -21,26 +21,29 @@ interface UseNeighborsParams {
}
export function useNeighbors({ nodeId, lastSeen, enabled = true }: UseNeighborsParams) {
return useQuery({
queryKey: ['neighbors', nodeId, lastSeen],
queryFn: async (): Promise<Neighbor[]> => {
if (!nodeId) return [];
const params = new URLSearchParams();
if (lastSeen !== null && lastSeen !== undefined) {
params.append('lastSeen', lastSeen.toString());
}
const url = `/api/meshcore/node/${nodeId}/neighbors${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(buildApiUrl(url));
if (!response.ok) {
throw new Error(`Failed to fetch neighbors: ${response.statusText}`);
}
return response.json();
return useQuery(
NodeService.method.getNodeNeighbors,
{
publicKey: nodeId ?? '',
lastSeen: lastSeen ?? undefined,
},
enabled: enabled && !!nodeId,
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
{
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,
})),
enabled: enabled && !!nodeId,
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
},
);
}
+87 -33
View File
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { buildApiUrl } from '@/lib/api';
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;
@@ -71,50 +73,102 @@ 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 };
}
}
return { error: 'An unexpected error occurred', code: 'UNKNOWN_ERROR', status: 500 };
}
export function useNodeData({ publicKey, limit = 50, enabled = true }: UseNodeDataParams) {
return useQuery<NodeData, NodeError>({
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" } as NodeError;
throw { error: 'Public key is required', code: 'MISSING_PUBLIC_KEY', status: 400 } as NodeError & {
status: number;
};
}
const params = new URLSearchParams();
if (limit !== 50) {
params.append('limit', limit.toString());
try {
const res = await nodeClient.getNode({ publicKey, limit });
return toNodeData(res);
} catch (err) {
throw toNodeError(err);
}
const url = `/api/meshcore/node/${publicKey}${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(buildApiUrl(url));
// Handle specific error responses
if (!response.ok) {
let errorData: NodeError;
try {
errorData = await response.json();
} catch {
errorData = {
error: `HTTP ${response.status}: ${response.statusText}`,
code: 'UNKNOWN_ERROR'
};
}
// Add status information to error for better handling
throw {
...errorData,
status: response.status
} as NodeError & { status: number };
}
return response.json();
},
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 as NodeError & { status?: number })?.status;
const status = error?.status;
if (status && status >= 400 && status < 500) {
return false;
}
+2 -2
View File
@@ -32,8 +32,8 @@ export function useQueryParams<T extends Record<string, any>>(defaultValues: T =
// (not from our own updates)
useEffect(() => {
const newState = { ...defaultValues };
searchParams.forEach((value, key) => {
searchParams?.forEach((value, key) => {
// Don't auto-convert 'q' (query) parameter to number since it should always be a string
if (key !== 'q' && !isNaN(Number(value)) && value !== '') {
newState[key as keyof T] = Number(value) as T[keyof T];
+62 -75
View File
@@ -1,6 +1,6 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { buildApiUrl } from "@/lib/api";
import { useQuery } from "@connectrpc/connect-query";
import { StatsService } from "@/gen/meshexplorer/v1/stats_pb";
interface TotalNodesResponse {
total_nodes: number;
@@ -41,108 +41,95 @@ const STALE_TIME = 5 * 60 * 1000; // 5 minutes
const GC_TIME = 10 * 60 * 1000; // 10 minutes
export function useTotalNodes(region?: string) {
return useQuery<TotalNodesResponse>({
queryKey: ['stats', 'total-nodes', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/total-nodes${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch total nodes: ${response.statusText}`);
}
return response.json();
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,
});
);
}
export function useNodesOverTime(region?: string) {
return useQuery<NodesOverTimeResponse>({
queryKey: ['stats', 'nodes-over-time', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/nodes-over-time${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch nodes over time: ${response.statusText}`);
}
return response.json();
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,
});
);
}
export function usePopularChannels(region?: string) {
return useQuery<PopularChannelsResponse>({
queryKey: ['stats', 'popular-channels', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/popular-channels${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch popular channels: ${response.statusText}`);
}
return response.json();
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,
});
);
}
export function useRepeaterPrefixes(region?: string) {
return useQuery<RepeaterPrefixesResponse>({
queryKey: ['stats', 'repeater-prefixes', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/repeater-prefixes${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch repeater prefixes: ${response.statusText}`);
}
return response.json();
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,
});
);
}
export function useUnusedPrefixes(region?: string) {
const { data: repeaterPrefixesData, isLoading, error } = useRepeaterPrefixes(region);
const unusedPrefixes = React.useMemo(() => {
if (!repeaterPrefixesData?.data) return [];
// Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF)
const allPrefixes = [];
for (let i = 1; i < 255; i++) {
allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase());
}
// Get used prefixes from the API response
const usedPrefixes = new Set(repeaterPrefixesData.data.map(row => row.prefix));
// Find unused prefixes
return allPrefixes.filter(prefix => !usedPrefixes.has(prefix));
}, [repeaterPrefixesData?.data]);
return {
data: unusedPrefixes,
isLoading,
+12
View File
@@ -0,0 +1,12 @@
import { createClient } from "@connectrpc/connect";
import { transport } from "./transport";
import { MapService } from "@/gen/meshexplorer/v1/map_pb";
import { NodeService } from "@/gen/meshexplorer/v1/node_pb";
import { ChatService } from "@/gen/meshexplorer/v1/chat_pb";
// Promise-based clients for the imperative call sites that don't fit the
// connect-query hook shape (the map's manual fetch, batched search, and the
// infinite-scroll chat query). The simple hooks use connect-query directly.
export const mapClient = createClient(MapService, transport);
export const nodeClient = createClient(NodeService, transport);
export const chatClient = createClient(ChatService, transport);
@@ -0,0 +1,9 @@
import { createConnectTransport } from "@connectrpc/connect-web";
import { getApiBaseUrl } from "@/lib/api";
// ConnectRPC services are served under the `/api` prefix (see
// src/pages/api/[[...connect]].ts), alongside the legacy REST routes.
// Honors NEXT_PUBLIC_API_URL the same way buildApiUrl() does.
export const transport = createConnectTransport({
baseUrl: `${getApiBaseUrl()}/api`,
});
@@ -0,0 +1,14 @@
import { nextJsApiRouter } from "@connectrpc/connect-next";
import routes from "@/server/connect/routes";
import { validationInterceptor } from "@/server/connect/validation";
// Serves every ConnectRPC service under /api/<package>.<Service>/<Method>.
// Next only treats files under pages/api/** as API routes, so the catch-all
// lives here (the default /api prefix matches the file location). protovalidate
// runs on every request via the interceptor before handlers see the message.
const { handler, config } = nextJsApiRouter({
routes,
interceptors: [validationInterceptor],
});
export { handler as default, config };
+127
View File
@@ -0,0 +1,127 @@
import type { MessageInitShape } from "@bufbuild/protobuf";
import type { ServiceImpl } from "@connectrpc/connect";
import { ChatService } from "@/gen/meshexplorer/v1/chat_pb";
import type { ChatMessageSchema } from "@/gen/meshexplorer/v1/chat_pb";
import { getLatestChatMessages } from "@/lib/clickhouse/actions";
import {
createClickHouseStreamer,
createChatMessagesStreamerConfig,
} from "@/lib/clickhouse/streaming";
import { decryptMeshcoreGroupMessage } from "@/lib/meshcore";
const PUBLIC_MESHCORE_KEY = "izOH6cXN6mrJ5e26oRXNcg==";
interface ChatRow {
ingest_timestamp: string;
mesh_timestamp: string;
channel_hash: string;
mac: string;
encrypted_message: string;
message_count: number;
origin_path_info: Array<[string, string, string, string, string]>;
message_id: string;
}
type ParsedMessage = {
timestamp: number;
msgType: number;
sender: string;
text: string;
rawText: string;
};
function toChatMessage(
row: ChatRow,
decrypted: ParsedMessage | null,
): MessageInitShape<typeof ChatMessageSchema> {
return {
ingestTimestamp: row.ingest_timestamp,
meshTimestamp: row.mesh_timestamp,
channelHash: row.channel_hash,
mac: row.mac,
encryptedMessage: row.encrypted_message,
messageCount: row.message_count,
originPathInfo: (row.origin_path_info ?? []).map(
([origin, originPubkey, path, broker, topic]) => ({
origin,
originPubkey,
path,
broker,
topic,
}),
),
messageId: row.message_id,
decrypted: decrypted
? {
timestamp: decrypted.timestamp,
msgType: decrypted.msgType,
sender: decrypted.sender,
text: decrypted.text,
rawText: decrypted.rawText,
}
: undefined,
};
}
async function decryptRow(row: ChatRow, keys: string[]): Promise<ParsedMessage | null> {
try {
const decrypted = (await decryptMeshcoreGroupMessage({
encrypted_message: row.encrypted_message,
mac: row.mac,
channel_hash: row.channel_hash,
knownKeys: keys,
parse: true,
})) as ParsedMessage | null;
return decrypted ?? null;
} catch {
return null;
}
}
export const chatServiceImpl: ServiceImpl<typeof ChatService> = {
async getChat(req) {
const messages = (await getLatestChatMessages({
limit: req.limit ?? 20,
before: req.before,
after: req.after,
channelId: req.channelId,
region: req.region,
})) as ChatRow[];
const keys = req.decrypt ? [PUBLIC_MESHCORE_KEY, ...req.privateKeys] : [];
const out: MessageInitShape<typeof ChatMessageSchema>[] = [];
for (const row of messages) {
if (req.decrypt) {
const decrypted = await decryptRow(row, keys);
// Match the REST endpoint: only emit messages that decrypted.
if (decrypted) {
out.push(toChatMessage(row, decrypted));
}
} else {
out.push(toChatMessage(row, null));
}
}
return { messages: out };
},
async *streamChat(req) {
const channelId = req.channelId ? req.channelId.toLowerCase() : undefined;
const config = createChatMessagesStreamerConfig(channelId, req.region);
config.pollInterval = req.pollInterval ?? 1000;
config.maxRowsPerPoll = req.maxRows ?? 500;
config.skipInitialMessages = req.skipInitialMessages;
const streamer = createClickHouseStreamer<ChatRow>(config);
const keys = req.decrypt ? [PUBLIC_MESHCORE_KEY, ...req.privateKeys] : [];
const params: Record<string, unknown> = {};
if (channelId) params.channelId = channelId;
for await (const result of streamer(params)) {
const row = result.row;
const decrypted = req.decrypt ? await decryptRow(row, keys) : null;
yield toChatMessage(row, decrypted);
}
},
};
+50
View File
@@ -0,0 +1,50 @@
import type { ServiceImpl } from "@connectrpc/connect";
import { MapService } from "@/gen/meshexplorer/v1/map_pb";
import { getNodePositions, getAllNodeNeighbors } from "@/lib/clickhouse/actions";
import { numToParam, toNeighborEdge, type NeighborEdgeRow } from "./mappers";
export const mapServiceImpl: ServiceImpl<typeof MapService> = {
async getMap(req) {
const minLat = numToParam(req.minLat);
const maxLat = numToParam(req.maxLat);
const minLng = numToParam(req.minLng);
const maxLng = numToParam(req.maxLng);
const lastSeen = numToParam(req.lastSeen);
const positions = await getNodePositions({
minLat,
maxLat,
minLng,
maxLng,
nodeTypes: req.nodeTypes,
lastSeen,
});
let neighbors: NeighborEdgeRow[] = [];
if (req.includeNeighbors) {
neighbors = await getAllNodeNeighbors(
lastSeen,
minLat,
maxLat,
minLng,
maxLng,
req.nodeTypes,
req.region,
);
}
return {
nodes: positions.map((p) => ({
nodeId: p.node_id,
name: p.name ?? undefined,
shortName: p.short_name ?? undefined,
latitude: p.latitude,
longitude: p.longitude,
lastSeen: p.last_seen,
firstSeen: p.first_seen ?? undefined,
type: p.type,
})),
neighbors: neighbors.map(toNeighborEdge),
};
},
};
@@ -0,0 +1,41 @@
import type { MessageInitShape } from "@bufbuild/protobuf";
import type { NeighborEdgeSchema } from "@/gen/meshexplorer/v1/common_pb";
// Row shape returned by getAllNodeNeighbors() / getNodePositions() neighbor data.
export interface NeighborEdgeRow {
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;
}
/** Maps a ClickHouse neighbor-edge row to the NeighborEdge proto init shape. */
export function toNeighborEdge(row: NeighborEdgeRow): MessageInitShape<typeof NeighborEdgeSchema> {
return {
sourceNode: row.source_node,
targetNode: row.target_node,
connectionType: row.connection_type,
packetCount: row.packet_count,
sourceName: row.source_name,
sourceLatitude: row.source_latitude,
sourceLongitude: row.source_longitude,
sourceHasLocation: row.source_has_location,
targetName: row.target_name,
targetLatitude: row.target_latitude,
targetLongitude: row.target_longitude,
targetHasLocation: row.target_has_location,
};
}
/** Converts an optional numeric request field to the `string | null` the actions expect. */
export function numToParam(n: number | undefined): string | null {
return n === undefined ? null : String(n);
}
@@ -0,0 +1,19 @@
import type { ServiceImpl } from "@connectrpc/connect";
import { NeighborsService } from "@/gen/meshexplorer/v1/neighbors_pb";
import { getAllNodeNeighbors } from "@/lib/clickhouse/actions";
import { numToParam, toNeighborEdge } from "./mappers";
export const neighborsServiceImpl: ServiceImpl<typeof NeighborsService> = {
async getAllNeighbors(req) {
const neighbors = await getAllNodeNeighbors(
numToParam(req.lastSeen),
numToParam(req.minLat),
numToParam(req.maxLat),
numToParam(req.minLng),
numToParam(req.maxLng),
req.nodeTypes,
req.region,
);
return { neighbors: neighbors.map(toNeighborEdge) };
},
};
+170
View File
@@ -0,0 +1,170 @@
import type { MessageInitShape } from "@bufbuild/protobuf";
import { Code, ConnectError, type ServiceImpl } from "@connectrpc/connect";
import { NodeService } from "@/gen/meshexplorer/v1/node_pb";
import type { SearchResultSchema } from "@/gen/meshexplorer/v1/node_pb";
import {
getMeshcoreNodeInfo,
getMeshcoreNodeNeighbors,
searchMeshcoreNodes,
} from "@/lib/clickhouse/actions";
interface SearchResultRow {
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;
}
function toSearchResult(row: SearchResultRow): MessageInitShape<typeof SearchResultSchema> {
return {
publicKey: row.public_key,
nodeName: row.node_name,
latitude: row.latitude ?? undefined,
longitude: row.longitude ?? undefined,
hasLocation: row.has_location,
isRepeater: row.is_repeater,
isChatNode: row.is_chat_node,
isRoomServer: row.is_room_server,
hasName: row.has_name,
firstHeard: row.first_heard,
lastSeen: row.last_seen,
broker: row.broker,
topic: row.topic,
};
}
export const nodeServiceImpl: ServiceImpl<typeof NodeService> = {
async getNode(req) {
const publicKey = req.publicKey.toUpperCase();
const nodeInfo = await getMeshcoreNodeInfo(publicKey, req.limit ?? 50);
if (!nodeInfo) {
throw new ConnectError(`node not found: ${publicKey}`, Code.NotFound);
}
const node = nodeInfo.node;
// recentAdverts / locationHistory come back as untyped JSON rows.
const adverts = nodeInfo.recentAdverts as Array<{
adv_timestamp: string;
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;
}>;
const locationHistory = nodeInfo.locationHistory as Array<{
mesh_timestamp: string;
latitude: number;
longitude: number;
}>;
return {
node: {
publicKey: node.public_key,
nodeName: node.node_name,
latitude: node.latitude ?? undefined,
longitude: node.longitude ?? undefined,
hasLocation: node.has_location,
isRepeater: node.is_repeater,
isChatNode: node.is_chat_node,
isRoomServer: node.is_room_server,
hasName: node.has_name,
broker: node.broker ?? undefined,
topic: node.topic ?? undefined,
firstSeen: node.first_seen,
lastSeen: node.last_seen,
},
recentAdverts: adverts.map((a) => ({
advTimestamp: a.adv_timestamp,
originPathPubkeyTuples: (a.origin_path_pubkey_tuples ?? []).map(
([origin, path, originPubkey]) => ({ origin, path, originPubkey }),
),
advertCount: a.advert_count,
earliestTimestamp: a.earliest_timestamp,
latestTimestamp: a.latest_timestamp,
latitude: a.latitude ?? undefined,
longitude: a.longitude ?? undefined,
isRepeater: a.is_repeater,
isChatNode: a.is_chat_node,
isRoomServer: a.is_room_server,
hasLocation: a.has_location,
packetHash: a.packet_hash,
})),
locationHistory: locationHistory.map((l) => ({
meshTimestamp: l.mesh_timestamp,
latitude: l.latitude,
longitude: l.longitude,
})),
mqtt: {
isUplinked: nodeInfo.mqtt.is_uplinked,
hasPackets: nodeInfo.mqtt.has_packets,
topics: nodeInfo.mqtt.topics.map((t) => ({
topic: t.topic,
broker: t.broker,
lastPacketTime: t.last_packet_time,
isRecent: t.is_recent,
})),
},
region: nodeInfo.region ?? undefined,
};
},
async getNodeNeighbors(req) {
const neighbors = await getMeshcoreNodeNeighbors(
req.publicKey.toUpperCase(),
req.lastSeen === undefined ? null : String(req.lastSeen),
);
return {
neighbors: neighbors.map((n) => ({
publicKey: n.public_key,
nodeName: n.node_name,
latitude: n.latitude ?? undefined,
longitude: n.longitude ?? undefined,
hasLocation: n.has_location,
isRepeater: n.is_repeater,
isChatNode: n.is_chat_node,
isRoomServer: n.is_room_server,
hasName: n.has_name,
directions: n.directions,
})),
};
},
async searchNodes(req) {
if (req.queries.length === 0) {
return { results: [] };
}
const queries = req.queries.map((q) => ({
query: q.query,
region: q.region,
lastSeen: q.lastSeen === undefined ? null : String(q.lastSeen),
limit: q.limit ?? 50,
exact: q.exact ?? false,
is_repeater: q.isRepeater,
}));
const grouped = (await searchMeshcoreNodes(queries)) as SearchResultRow[][];
return {
results: grouped.map((group) => ({
results: group.map(toSearchResult),
})),
};
},
};
@@ -0,0 +1,60 @@
import type { ServiceImpl } from "@connectrpc/connect";
import { PacketsService } from "@/gen/meshexplorer/v1/packets_pb";
import {
createClickHouseStreamer,
createMeshcorePacketsStreamerConfig,
} from "@/lib/clickhouse/streaming";
interface PacketRow {
ingest_timestamp: string;
mesh_timestamp: string;
broker: string;
topic: string;
packet: string;
path_len: number;
path: string;
route_type: number;
payload_type: number;
payload_version: number;
header: number;
origin_pubkey: string;
}
export const packetsServiceImpl: ServiceImpl<typeof PacketsService> = {
async *streamPackets(req) {
const originPubkey = req.originPubkey ? req.originPubkey.toUpperCase() : undefined;
const config = createMeshcorePacketsStreamerConfig(
req.region,
req.payloadType,
req.routeType,
originPubkey,
);
config.pollInterval = req.pollInterval ?? 500;
config.maxRowsPerPoll = req.maxRows ?? 10;
const streamer = createClickHouseStreamer<PacketRow>(config);
const params: Record<string, unknown> = {};
if (req.payloadType !== undefined) params.payloadType = req.payloadType;
if (req.routeType !== undefined) params.routeType = req.routeType;
if (originPubkey) params.originPubkey = originPubkey;
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: row.path_len,
path: row.path,
routeType: row.route_type,
payloadType: row.payload_type,
payloadVersion: row.payload_version,
header: row.header,
originPubkey: row.origin_pubkey,
};
}
},
};
+22
View File
@@ -0,0 +1,22 @@
import type { ConnectRouter } from "@connectrpc/connect";
import { MapService } from "@/gen/meshexplorer/v1/map_pb";
import { NeighborsService } from "@/gen/meshexplorer/v1/neighbors_pb";
import { NodeService } from "@/gen/meshexplorer/v1/node_pb";
import { StatsService } from "@/gen/meshexplorer/v1/stats_pb";
import { ChatService } from "@/gen/meshexplorer/v1/chat_pb";
import { PacketsService } from "@/gen/meshexplorer/v1/packets_pb";
import { mapServiceImpl } from "./map";
import { neighborsServiceImpl } from "./neighbors";
import { nodeServiceImpl } from "./node";
import { statsServiceImpl } from "./stats";
import { chatServiceImpl } from "./chat";
import { packetsServiceImpl } from "./packets";
export default function routes(router: ConnectRouter) {
router.service(MapService, mapServiceImpl);
router.service(NeighborsService, neighborsServiceImpl);
router.service(NodeService, nodeServiceImpl);
router.service(StatsService, statsServiceImpl);
router.service(ChatService, chatServiceImpl);
router.service(PacketsService, packetsServiceImpl);
}
+123
View File
@@ -0,0 +1,123 @@
import type { ServiceImpl } from "@connectrpc/connect";
import { StatsService } from "@/gen/meshexplorer/v1/stats_pb";
import { clickhouse } from "@/lib/clickhouse/clickhouse";
import {
generateRegionWhereClause,
generateRegionWhereClauseFromArray,
} from "@/lib/regionFilters";
export const statsServiceImpl: ServiceImpl<typeof StatsService> = {
async getTotalNodes(req) {
const regionFilter = generateRegionWhereClause(req.region);
const whereClause = regionFilter.whereClause ? `WHERE ${regionFilter.whereClause}` : "";
const query = `SELECT count(DISTINCT public_key) AS total_nodes FROM meshcore_adverts ${whereClause}`;
const resultSet = await clickhouse.query({ query, format: "JSONEachRow" });
const rows = (await resultSet.json()) as Array<{ total_nodes: number }>;
return { totalNodes: rows.length > 0 ? Number(rows[0].total_nodes) : 0 };
},
async getNodesOverTime(req) {
const regionFilter = generateRegionWhereClause(req.region);
const regionWhereClause = regionFilter.whereClause ? `WHERE ${regionFilter.whereClause}` : "";
const query = `
WITH all_nodes AS (
SELECT toDate(ingest_timestamp) AS day, public_key, latitude, longitude, is_repeater, is_room_server
FROM meshcore_adverts
${regionWhereClause}
),
all_days AS (
SELECT DISTINCT day FROM all_nodes
ORDER BY day ASC
),
rolling_window AS (
SELECT
d.day,
n.public_key,
n.latitude,
n.longitude,
n.is_repeater,
n.is_room_server
FROM all_days d
INNER JOIN all_nodes n ON n.day BETWEEN (d.day - INTERVAL 6 DAY) AND d.day
)
SELECT day,
count(DISTINCT public_key) AS cumulative_unique_nodes,
count(DISTINCT CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 THEN public_key END) AS nodes_with_location,
count(DISTINCT CASE WHEN latitude IS NULL OR longitude IS NULL OR latitude = 0 OR longitude = 0 THEN public_key END) AS nodes_without_location,
count(DISTINCT CASE WHEN is_repeater = 1 THEN public_key END) AS repeaters,
count(DISTINCT CASE WHEN is_room_server = 1 THEN public_key END) AS room_servers
FROM rolling_window
GROUP BY day
ORDER BY day ASC
`;
const resultSet = await clickhouse.query({ query, format: "JSONEachRow" });
const rows = (await resultSet.json()) as Array<{
day: string;
cumulative_unique_nodes: number;
nodes_with_location: number;
nodes_without_location: number;
repeaters: number;
room_servers: number;
}>;
return {
data: rows.map((r) => ({
day: r.day,
cumulativeUniqueNodes: Number(r.cumulative_unique_nodes),
nodesWithLocation: Number(r.nodes_with_location),
nodesWithoutLocation: Number(r.nodes_without_location),
repeaters: Number(r.repeaters),
roomServers: Number(r.room_servers),
})),
};
},
async getPopularChannels(req) {
const regionFilter = generateRegionWhereClauseFromArray(req.region);
const whereClause = regionFilter.whereClause ? `WHERE ${regionFilter.whereClause}` : "";
const query = `
SELECT channel_hash, count() AS message_count
FROM meshcore_public_channel_messages
${whereClause}
GROUP BY channel_hash
ORDER BY message_count DESC
LIMIT 10
`;
const resultSet = await clickhouse.query({ query, format: "JSONEachRow" });
const rows = (await resultSet.json()) as Array<{ channel_hash: string; message_count: number }>;
return {
data: rows.map((r) => ({
channelHash: r.channel_hash,
messageCount: Number(r.message_count),
})),
};
},
async getRepeaterPrefixes(req) {
const regionFilter = generateRegionWhereClause(req.region);
const regionWhereClause = regionFilter.whereClause ? `AND ${regionFilter.whereClause}` : "";
const query = `
SELECT
substring(public_key, 1, 2) as prefix,
count() as node_count,
groupArray(node_name) as node_names
FROM meshcore_adverts_latest
WHERE is_repeater = 1
AND last_seen >= now() - INTERVAL 2 DAY
${regionWhereClause}
GROUP BY prefix
ORDER BY node_count DESC, prefix ASC
`;
const resultSet = await clickhouse.query({ query, format: "JSONEachRow" });
const rows = (await resultSet.json()) as Array<{
prefix: string;
node_count: number;
node_names: string[];
}>;
return {
data: rows.map((r) => ({
prefix: r.prefix,
nodeNames: r.node_names,
})),
};
},
};
@@ -0,0 +1,39 @@
import type { DescMessage, MessageShape } from "@bufbuild/protobuf";
import { createValidator } from "@bufbuild/protovalidate";
import { Code, ConnectError, type Interceptor } from "@connectrpc/connect";
// A single validator instance compiles and caches CEL programs per message
// type, so reuse it across all requests.
const validator = createValidator();
function assertValid<Desc extends DescMessage>(schema: Desc, message: MessageShape<Desc>): void {
const result = validator.validate(schema, message);
if (result.kind === "invalid") {
// Surface the human-readable rule violations to the caller.
throw new ConnectError(result.error.message, Code.InvalidArgument);
}
if (result.kind === "error") {
// A rule failed to compile/evaluate — that's a server-side bug, not bad input.
throw new ConnectError(`request validation failed: ${result.error.message}`, Code.Internal);
}
}
/**
* Server interceptor that enforces protovalidate (buf.validate) rules on every
* inbound request message before it reaches a handler. Works for unary calls
* and for the single request message of server-streaming calls.
*/
export const validationInterceptor: Interceptor = (next) => async (req) => {
if (req.stream) {
const source = req.message;
const validated = (async function* () {
for await (const message of source) {
assertValid(req.method.input, message);
yield message;
}
})();
return next({ ...req, message: validated });
}
assertValid(req.method.input, req.message);
return next(req);
};