From abd421a2a13db03fd90fcf5aa31c7897382af608 Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Sun, 7 Sep 2025 02:22:20 +0200 Subject: [PATCH] some kind of node info page --- src/app/api-docs/page.tsx | 104 ++++++ src/app/api/chat/route.ts | 2 +- .../api/meshcore/node/[publicKey]/route.ts | 28 ++ src/app/meshcore/node/[publicKey]/page.tsx | 348 ++++++++++++++++++ src/components/MapIcons.tsx | 21 ++ src/lib/clickhouse/actions.ts | 125 +++++++ 6 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/app/api/meshcore/node/[publicKey]/route.ts create mode 100644 src/app/meshcore/node/[publicKey]/page.tsx diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx index b54a942..eb7b850 100644 --- a/src/app/api-docs/page.tsx +++ b/src/app/api-docs/page.tsx @@ -204,6 +204,101 @@ export default function ApiDocsPage() { + {/* Meshcore Node API */} +
+

Meshcore Node API

+
+

GET /api/meshcore/node/{`{publicKey}`}

+

Retrieve detailed information about a specific meshcore node including recent adverts and location history.

+ +

Path Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
publicKeystringYesThe public key of the meshcore node
+
+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
limitnumberNoNumber of recent adverts to return (default: 50)
+
+ +

Response Format

+
+
+{`{
+  "node": {
+    "public_key": "82D396A8754609E302A2A3FDB9210A1C67C7081606C16A89F77AD75C16E1DA1A",
+    "node_name": "🌲 Tree Room (hello)",
+    "latitude": 47.54969,
+    "longitude": -122.28085999999999,
+    "has_location": 1,
+    "is_repeater": 1,
+    "is_chat_node": 1,
+    "is_room_server": 0,
+    "has_name": 1,
+    "last_seen": "2025-09-07T00:59:18"
+  },
+  "recentAdverts": [
+    {
+      "mesh_timestamp": "2025-09-07T00:59:18",
+      "path": "7ffb7e",
+      "path_len": 3,
+      "latitude": 47.54969,
+      "longitude": -122.28085999999999,
+      "is_repeater": 1,
+      "is_chat_node": 1,
+      "is_room_server": 0,
+      "has_location": 1
+    }
+  ],
+  "locationHistory": [
+    {
+      "mesh_timestamp": "2025-09-07T00:59:18",
+      "latitude": 47.54969,
+      "longitude": -122.28085999999999,
+      "path": "7ffb7e",
+      "path_len": 3
+    }
+  ]
+}`}
+                    
+
+
+
+ {/* Stats APIs */}

Stats APIs

@@ -428,6 +523,15 @@ export default function ApiDocsPage() { + +
+

Get meshcore node details

+
+
+{`GET /api/meshcore/node/82D396A8754609E302A2A3FDB9210A1C67C7081606C16A89F77AD75C16E1DA1A?limit=100`}
+                      
+
+
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 05f10e0..aa32219 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -37,7 +37,7 @@ export async function GET(req: Request) { ...message, decrypted }); - } + } } catch (error) { // Skip messages that fail to decrypt console.warn("Failed to decrypt message:", error); diff --git a/src/app/api/meshcore/node/[publicKey]/route.ts b/src/app/api/meshcore/node/[publicKey]/route.ts new file mode 100644 index 0000000..8146f1e --- /dev/null +++ b/src/app/api/meshcore/node/[publicKey]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { getMeshcoreNodeInfo } from "@/lib/clickhouse/actions"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ publicKey: string }> } +) { + try { + const { publicKey } = await params; + const { searchParams } = new URL(req.url); + const limit = parseInt(searchParams.get("limit") || "50", 10); + + if (!publicKey) { + return NextResponse.json({ error: "Public key is required" }, { status: 400 }); + } + + const nodeInfo = await getMeshcoreNodeInfo(publicKey, limit); + + if (!nodeInfo) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + return NextResponse.json(nodeInfo); + } catch (error) { + console.error("Error fetching meshcore node info:", error); + return NextResponse.json({ error: "Failed to fetch node info" }, { status: 500 }); + } +} diff --git a/src/app/meshcore/node/[publicKey]/page.tsx b/src/app/meshcore/node/[publicKey]/page.tsx new file mode 100644 index 0000000..62b5787 --- /dev/null +++ b/src/app/meshcore/node/[publicKey]/page.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import moment from "moment"; +import { formatPublicKey } from "@/lib/meshcore"; +import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils"; + +interface NodeInfo { + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + last_seen: string; +} + +interface Advert { + mesh_timestamp: string; + path: string; + path_len: number; + latitude: number | null; + longitude: number | null; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_location: number; + origin_pubkey: string; + full_path: string; +} + +interface LocationHistory { + mesh_timestamp: string; + latitude: number; + longitude: number; + path: string; + path_len: number; + origin_pubkey: string; + full_path: string; +} + +interface MqttInfo { + is_uplinked: boolean; + last_uplink_time: string | null; + has_packets: boolean; +} + +interface NodeData { + node: NodeInfo; + recentAdverts: Advert[]; + locationHistory: LocationHistory[]; + mqtt: MqttInfo; +} + +export default function MeshcoreNodePage() { + const params = useParams(); + const publicKey = params.publicKey as string; + const [nodeData, setNodeData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!publicKey) return; + + const fetchNodeData = async () => { + try { + setLoading(true); + const response = await fetch(`/api/meshcore/node/${publicKey}`); + + if (response.status === 404) { + setError("Node not found"); + return; + } + + if (!response.ok) { + throw new Error("Failed to fetch node data"); + } + + const data = await response.json(); + setNodeData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + + fetchNodeData(); + }, [publicKey]); + + if (loading) { + return ( +
+
+
+

Loading node information...

+
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+

Error

+

{error}

+
+
+ ); + } + + if (!nodeData) { + return ( +
+
+
+

No Data

+

No node data available

+
+
+ ); + } + + const { node, recentAdverts, locationHistory, mqtt } = nodeData; + + return ( +
+
+ {/* Header */} +
+
+
+
+

+ {node.has_name ? getNameIconLabel(node.node_name) : "Unknown Node"} +

+ {node.has_name && ( +

+ {node.node_name} +

+ )} +

+ {formatPublicKey(node.public_key)} +

+
+
+
+ {node.is_repeater && ( + + Repeater + + )} + {node.is_chat_node && ( + + Chat Node + + )} + {node.is_room_server && ( + + Room Server + + )} +
+

+ Last seen: {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC +

+
+
+
+
+ + {/* Node Details */} +
+
+

Node Details

+
+
+
+
+
Public Key
+
+ {node.public_key} +
+
+
+
Node Name
+
+ {node.has_name ? node.node_name : "Not set"} +
+
+
+
Current Location
+
+ {node.has_location && node.latitude && node.longitude ? ( + + {node.latitude.toFixed(6)}, {node.longitude.toFixed(6)} + + ) : ( + "No location data" + )} +
+
+
+
Last Seen
+
+ {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC +
+ + {moment.utc(node.last_seen).local().fromNow()} + +
+
+
+
MQTT Uplink
+
+
+ + {mqtt.is_uplinked ? 'Connected' : 'Not Connected'} + +
+ {mqtt.has_packets && mqtt.last_uplink_time && ( +
+ Last packet: {moment.utc(mqtt.last_uplink_time).format('YYYY-MM-DD HH:mm:ss')} UTC +
+ + {moment.utc(mqtt.last_uplink_time).local().fromNow()} + +
+ )} +
+
+
+
+
+ +
+ {/* Recent Adverts */} +
+
+

Recent Adverts

+

Latest {recentAdverts.length} adverts with path information

+
+
+ + + + + + + + + + + + {recentAdverts.map((advert, index) => ( + + + + + + + + ))} + +
TimestampPathLengthLocationType
+ {moment.utc(advert.mesh_timestamp).format('MM-DD HH:mm:ss')} + + {advert.full_path ? advert.full_path.match(/.{1,2}/g)?.join(' ') || advert.full_path : "-"} + + {advert.path_len} + + {advert.latitude && advert.longitude ? ( + + {advert.latitude.toFixed(4)}, {advert.longitude.toFixed(4)} + + ) : ( + - + )} + +
+ {advert.is_repeater && ( + + R + + )} + {advert.is_chat_node && ( + + C + + )} + {advert.is_room_server && ( + + S + + )} +
+
+
+
+ + {/* Location History */} +
+
+

Location History

+

Recent location updates (last 30 days)

+
+
+ + + + + + + + + + {locationHistory.map((location, index) => ( + + + + + + ))} + +
TimestampLatitudeLongitude
+ {moment.utc(location.mesh_timestamp).format('MM-DD HH:mm:ss')} + + {location.latitude.toFixed(6)} + + {location.longitude.toFixed(6)} +
+
+
+
+
+
+ ); +} diff --git a/src/components/MapIcons.tsx b/src/components/MapIcons.tsx index 118c988..bf7d892 100644 --- a/src/components/MapIcons.tsx +++ b/src/components/MapIcons.tsx @@ -161,6 +161,27 @@ export function PopupContent({ node }: PopupContentProps) { ) : (
First seen: -
)} + {node.type === "meshcore" && ( +
+ e.currentTarget.style.backgroundColor = '#2563eb'} + onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#3b82f6'} + > + View Node Details → + +
+ )} ); } diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index c6d2e2e..19af73e 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -93,4 +93,129 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel console.error('ClickHouse error in getLatestChatMessages:', error); throw error; } +} + +export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) { + try { + // Get basic node info from the latest advert + const nodeInfoQuery = ` + SELECT + public_key, + node_name, + latitude, + longitude, + has_location, + is_repeater, + is_chat_node, + is_room_server, + has_name, + mesh_timestamp as last_seen + FROM meshcore_adverts + WHERE public_key = {publicKey:String} + ORDER BY mesh_timestamp DESC + LIMIT 1 + `; + + const nodeInfoResult = await clickhouse.query({ + query: nodeInfoQuery, + query_params: { publicKey }, + format: 'JSONEachRow' + }); + const nodeInfo = await nodeInfoResult.json(); + + if (!nodeInfo || nodeInfo.length === 0) { + return null; + } + + // Get recent adverts with path information + const advertsQuery = ` + SELECT + mesh_timestamp, + hex(path) as path, + path_len, + latitude, + longitude, + is_repeater, + is_chat_node, + is_room_server, + has_location, + hex(origin_pubkey) as origin_pubkey, + concat(hex(path), substring(hex(origin_pubkey), 1, 4)) as full_path + FROM meshcore_adverts + WHERE public_key = {publicKey:String} + ORDER BY mesh_timestamp DESC + LIMIT {limit:UInt32} + `; + + const advertsResult = await clickhouse.query({ + query: advertsQuery, + query_params: { publicKey, limit }, + format: 'JSONEachRow' + }); + const adverts = await advertsResult.json(); + + // Get location history (unique locations over time) - last 30 days only + const locationHistoryQuery = ` + SELECT + mesh_timestamp, + latitude, + longitude, + hex(path) as path, + path_len, + hex(origin_pubkey) as origin_pubkey, + concat(hex(path), substring(hex(origin_pubkey), 1, 4)) as full_path + FROM ( + SELECT + mesh_timestamp, + latitude, + longitude, + path, + path_len, + origin_pubkey, + row_number() OVER (PARTITION BY round(latitude, 6), round(longitude, 6) ORDER BY mesh_timestamp DESC) as rn + FROM meshcore_adverts + WHERE public_key = {publicKey:String} + AND latitude IS NOT NULL + AND longitude IS NOT NULL + AND mesh_timestamp >= now() - INTERVAL 30 DAY + ) + WHERE rn = 1 + ORDER BY mesh_timestamp DESC + LIMIT 100 + `; + + const locationResult = await clickhouse.query({ + query: locationHistoryQuery, + query_params: { publicKey }, + format: 'JSONEachRow' + }); + const locationHistory = await locationResult.json(); + + // Check MQTT uplink status and last packet time + const mqttQuery = ` + SELECT + count() > 0 as has_packets, + max(ingest_timestamp) as last_uplink_time, + max(ingest_timestamp) >= now() - INTERVAL 7 DAY as is_uplinked + FROM meshcore_packets + WHERE hex(origin_pubkey) = {publicKey:String} + `; + + const mqttResult = await clickhouse.query({ + query: mqttQuery, + query_params: { publicKey }, + format: 'JSONEachRow' + }); + const mqttData = await mqttResult.json(); + + return { + node: nodeInfo[0], + recentAdverts: adverts, + locationHistory: locationHistory, + mqtt: mqttData[0] || { is_uplinked: false, last_uplink_time: null } + }; + } catch (error) { + console.error('ClickHouse error in getMeshcoreNodeInfo:', error); + throw error; + } } \ No newline at end of file