some kind of node info page

This commit is contained in:
ajvpot
2025-09-07 02:22:20 +02:00
parent 8a4b591776
commit abd421a2a1
6 changed files with 627 additions and 1 deletions

View File

@@ -204,6 +204,101 @@ export default function ApiDocsPage() {
</div>
</section>
{/* Meshcore Node API */}
<section>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Meshcore Node API</h2>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">GET /api/meshcore/node/{`{publicKey}`}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve detailed information about a specific meshcore node including recent adverts and location history.</p>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Path Parameters</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Parameter</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Required</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">publicKey</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">string</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">Yes</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">The public key of the meshcore node</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2 mt-6">Query Parameters</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Parameter</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Required</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">limit</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">number</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">No</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Number of recent adverts to return (default: 50)</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2 mt-6">Response Format</h4>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 dark:text-green-300 text-sm">
{`{
"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
}
]
}`}
</pre>
</div>
</div>
</section>
{/* Stats APIs */}
<section>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Stats APIs</h2>
@@ -428,6 +523,15 @@ export default function ApiDocsPage() {
</pre>
</div>
</div>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-6">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Get meshcore node details</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-blue-400 dark:text-blue-300 text-sm">
{`GET /api/meshcore/node/82D396A8754609E302A2A3FDB9210A1C67C7081606C16A89F77AD75C16E1DA1A?limit=100`}
</pre>
</div>
</div>
</div>
</section>
</div>

View File

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

View File

@@ -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 });
}
}

View File

@@ -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<NodeData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-300">Loading node information...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 dark:text-red-400 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Error</h1>
<p className="text-gray-600 dark:text-gray-300">{error}</p>
</div>
</div>
);
}
if (!nodeData) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
<div className="text-center">
<div className="text-gray-600 dark:text-gray-300 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">No Data</h1>
<p className="text-gray-600 dark:text-gray-300">No node data available</p>
</div>
</div>
);
}
const { node, recentAdverts, locationHistory, mqtt } = nodeData;
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="bg-white dark:bg-neutral-900 shadow rounded-lg mb-6">
<div className="px-6 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{node.has_name ? getNameIconLabel(node.node_name) : "Unknown Node"}
</h1>
{node.has_name && (
<p className="text-lg text-gray-700 dark:text-gray-300 mb-2">
{node.node_name}
</p>
)}
<p className="text-gray-600 dark:text-gray-300 font-mono text-sm">
{formatPublicKey(node.public_key)}
</p>
</div>
<div className="text-right">
<div className="flex space-x-2 mb-2">
{node.is_repeater && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Repeater
</span>
)}
{node.is_chat_node && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Chat Node
</span>
)}
{node.is_room_server && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
Room Server
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Last seen: {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
</p>
</div>
</div>
</div>
</div>
{/* Node Details */}
<div className="mb-6 bg-white dark:bg-neutral-900 shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Node Details</h2>
</div>
<div className="px-6 py-4">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Public Key</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{node.public_key}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Node Name</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{node.has_name ? node.node_name : "Not set"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current Location</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{node.has_location && node.latitude && node.longitude ? (
<span>
{node.latitude.toFixed(6)}, {node.longitude.toFixed(6)}
</span>
) : (
"No location data"
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Last Seen</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
<br />
<span className="text-gray-500 dark:text-gray-400">
{moment.utc(node.last_seen).local().fromNow()}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">MQTT Uplink</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
mqtt.is_uplinked
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{mqtt.is_uplinked ? 'Connected' : 'Not Connected'}
</span>
</div>
{mqtt.has_packets && mqtt.last_uplink_time && (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Last packet: {moment.utc(mqtt.last_uplink_time).format('YYYY-MM-DD HH:mm:ss')} UTC
<br />
<span className="text-gray-400">
{moment.utc(mqtt.last_uplink_time).local().fromNow()}
</span>
</div>
)}
</dd>
</div>
</dl>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Adverts */}
<div className="bg-white dark:bg-neutral-900 shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Recent Adverts</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Latest {recentAdverts.length} adverts with path information</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-neutral-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Path</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Length</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Location</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Type</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-900 divide-y divide-gray-200 dark:divide-gray-700">
{recentAdverts.map((advert, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{moment.utc(advert.mesh_timestamp).format('MM-DD HH:mm:ss')}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100 font-mono">
{advert.full_path ? advert.full_path.match(/.{1,2}/g)?.join(' ') || advert.full_path : "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{advert.path_len}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{advert.latitude && advert.longitude ? (
<span>
{advert.latitude.toFixed(4)}, {advert.longitude.toFixed(4)}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex space-x-1">
{advert.is_repeater && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
R
</span>
)}
{advert.is_chat_node && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
C
</span>
)}
{advert.is_room_server && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
S
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Location History */}
<div className="bg-white dark:bg-neutral-900 shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Location History</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Recent location updates (last 30 days)</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-neutral-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Latitude</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Longitude</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-900 divide-y divide-gray-200 dark:divide-gray-700">
{locationHistory.map((location, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{moment.utc(location.mesh_timestamp).format('MM-DD HH:mm:ss')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{location.latitude.toFixed(6)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{location.longitude.toFixed(6)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -161,6 +161,27 @@ export function PopupContent({ node }: PopupContentProps) {
) : (
<div><b>First seen:</b> -</div>
)}
{node.type === "meshcore" && (
<div style={{marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #e5e7eb'}}>
<a
href={`/meshcore/node/${node.node_id}`}
style={{
display: 'inline-block',
padding: '4px 8px',
backgroundColor: '#3b82f6',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#2563eb'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#3b82f6'}
>
View Node Details
</a>
</div>
)}
</div>
);
}

View File

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