Search batching, display nodes on graph, spinner for loading neighbors

This commit is contained in:
ajvpot
2025-09-10 01:33:29 +02:00
parent 256886f2e8
commit 9eb1c4d62c
23 changed files with 1406 additions and 519 deletions
+24
View File
@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.87.1",
"@types/leaflet": "^1.9.19",
"@types/qrcode": "^1.5.5",
"@yornaath/batshit": "^0.10.1",
"aes-js": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clickhouse": "^2.6.0",
@@ -29,6 +30,7 @@
"react-d3-tree": "^3.6.6",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-tiny-popover": "^8.1.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
@@ -2075,6 +2077,19 @@
"win32"
]
},
"node_modules/@yornaath/batshit": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.10.1.tgz",
"integrity": "sha512-WGZ1WNoiVN6CLf28O73+6SCf+2lUn4U7TLGM9f4zOad0pn9mdoXIq8cwu3Kpf7N2OTYgWGK4eQPTflwFlduDGA==",
"dependencies": {
"@yornaath/batshit-devtools": "^1.7.1"
}
},
"node_modules/@yornaath/batshit-devtools": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz",
"integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ=="
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5719,6 +5734,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-tiny-popover": {
"version": "8.1.6",
"resolved": "https://registry.npmjs.org/react-tiny-popover/-/react-tiny-popover-8.1.6.tgz",
"integrity": "sha512-jeZnGqHxb5TX7pCzpqLoVJned7DTVnLrLoCQQGFTyvlxXB/QUaet7O0krG22t5FReMBH035SLnzThKvk8tIfsg==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+2
View File
@@ -15,6 +15,7 @@
"@tanstack/react-query": "^5.87.1",
"@types/leaflet": "^1.9.19",
"@types/qrcode": "^1.5.5",
"@yornaath/batshit": "^0.10.1",
"aes-js": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clickhouse": "^2.6.0",
@@ -30,6 +31,7 @@
"react-d3-tree": "^3.6.6",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-tiny-popover": "^8.1.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
+73 -43
View File
@@ -1,70 +1,100 @@
import { NextResponse } from "next/server";
import { searchMeshcoreNodes } from "@/lib/clickhouse/actions";
export async function GET(req: Request) {
interface SearchQueryParams {
query?: string;
region?: string;
lastSeen?: string | null;
limit: number;
exact: boolean;
is_repeater?: boolean;
}
export async function POST(req: Request) {
try {
const { searchParams } = new URL(req.url);
const query = searchParams.get("q");
const region = searchParams.get("region");
const lastSeen = searchParams.get("lastSeen");
const limit = parseInt(searchParams.get("limit") || "50", 10);
const body = await req.json();
// Validate limit
if (limit < 1 || limit > 200) {
// Validate that body contains an array of queries
if (!Array.isArray(body.queries)) {
return NextResponse.json({
error: "Limit must be between 1 and 200",
code: "INVALID_LIMIT"
error: "Body must contain a 'queries' array",
code: "INVALID_BODY"
}, { status: 400 });
}
// If no query provided, return empty results
if (!query || query.trim().length === 0) {
// Validate queries array length
if (body.queries.length === 0) {
return NextResponse.json({
results: [],
total: 0,
query: query || "",
region: region || null
total: 0
});
}
// Validate query length
if (query.length > 100) {
if (body.queries.length > 50) {
return NextResponse.json({
error: "Query too long (max 100 characters)",
code: "QUERY_TOO_LONG"
error: "Maximum 50 queries allowed per batch",
code: "TOO_MANY_QUERIES"
}, { status: 400 });
}
// Validate lastSeen parameter
let lastSeenValue: string | null = null;
if (lastSeen !== null) {
const lastSeenNum = parseInt(lastSeen, 10);
if (isNaN(lastSeenNum) || lastSeenNum < 0) {
return NextResponse.json({
error: "lastSeen must be a positive number (seconds)",
code: "INVALID_LAST_SEEN"
}, { status: 400 });
// Validate and normalize each query
const normalizedQueries: SearchQueryParams[] = body.queries.map((queryObj: any, index: number) => {
// Validate limit for each query
const limit = parseInt(queryObj.limit || "50", 10);
if (limit < 1 || limit > 200) {
throw new Error(`Query ${index}: Limit must be between 1 and 200`);
}
lastSeenValue = lastSeen;
}
// Validate query length
if (queryObj.query && queryObj.query.length > 100) {
throw new Error(`Query ${index}: Query too long (max 100 characters)`);
}
// Validate lastSeen parameter
let lastSeenValue: string | null = null;
if (queryObj.lastSeen !== null && queryObj.lastSeen !== undefined) {
const lastSeenNum = parseInt(queryObj.lastSeen, 10);
if (isNaN(lastSeenNum) || lastSeenNum < 0) {
throw new Error(`Query ${index}: lastSeen must be a positive number (seconds)`);
}
lastSeenValue = queryObj.lastSeen.toString();
}
return {
query: queryObj.query?.trim() || undefined,
region: queryObj.region || undefined,
lastSeen: lastSeenValue,
limit,
exact: Boolean(queryObj.exact),
is_repeater: queryObj.is_repeater !== undefined ? Boolean(queryObj.is_repeater) : undefined
};
});
const results = await searchMeshcoreNodes({
query: query.trim(),
region: region || undefined,
lastSeen: lastSeenValue,
limit
// Execute batch search
const results = await searchMeshcoreNodes(normalizedQueries);
// Format response - array of arrays, one per query
const formattedResults = normalizedQueries.map((queryParams: SearchQueryParams, index: number) => {
const queryResults = Array.isArray(results)
? (Array.isArray(results[index]) ? results[index] : [])
: [];
return queryResults;
});
return NextResponse.json({
results,
total: results.length,
query: query.trim(),
region: region || null,
lastSeen: lastSeenValue,
limit
results: formattedResults
});
} catch (error) {
console.error("Error searching meshcore nodes:", error);
console.error("Error in batch search:", error);
// Handle validation errors
if (error instanceof Error && error.message.includes('Query ')) {
return NextResponse.json({
error: error.message,
code: "VALIDATION_ERROR"
}, { status: 400 });
}
// Check if it's a ClickHouse connection error
if (error instanceof Error && error.message.includes('ClickHouse')) {
@@ -75,7 +105,7 @@ export async function GET(req: Request) {
}
return NextResponse.json({
error: "Failed to search nodes",
error: "Failed to execute batch search",
code: "INTERNAL_ERROR"
}, { status: 500 });
}
+1 -1
View File
@@ -10,7 +10,7 @@ export async function GET(req: Request) {
const regionFilter = generateRegionWhereClause(region);
const whereClause = regionFilter.whereClause ? `WHERE ${regionFilter.whereClause}` : '';
const query = `SELECT count() AS total_nodes FROM meshcore_adverts_latest ${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 }>;
+26
View File
@@ -153,6 +153,32 @@ html {
position: relative;
}
.custom-node-marker--loading {
position: relative;
}
.custom-node-marker--loading::after {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 2px solid transparent;
border-top: 2px solid #2563eb; /* blue-600 */
border-radius: 50%;
animation: node-spinner 1s linear infinite;
}
@keyframes node-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.map-spinner {
width: 28px;
height: 28px;
+48 -119
View File
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import moment from "moment";
@@ -11,59 +10,10 @@ import AdvertDetails from "@/components/AdvertDetails";
import ContactQRCode from "@/components/ContactQRCode";
import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext";
import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors";
import { useNodeData, type NodeData, type NodeInfo, type Advert, type LocationHistory, type MqttInfo, type NodeError } from "@/hooks/useNodeData";
import { ArrowRightEndOnRectangleIcon, ArrowRightStartOnRectangleIcon } from "@heroicons/react/24/outline";
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;
}
interface Advert {
group_id: number;
origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples
advert_count: number;
earliest_timestamp: string;
latest_timestamp: string;
latitude: number | null;
longitude: number | null;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_location: number;
}
interface LocationHistory {
mesh_timestamp: string;
latitude: number;
longitude: number;
}
interface MqttTopic {
topic: string;
broker: string;
last_packet_time: string;
is_recent: boolean;
}
interface MqttInfo {
is_uplinked: boolean;
has_packets: boolean;
topics: MqttTopic[];
}
interface NodeData {
node: NodeInfo;
recentAdverts: Advert[];
locationHistory: LocationHistory[];
mqtt: MqttInfo;
}
// Interfaces are now imported from useNodeData hook
// Function to determine node type based on capabilities
function getNodeType(node: NodeInfo): number {
@@ -77,72 +27,30 @@ export default function MeshcoreNodePage() {
const params = useParams();
const publicKey = params.publicKey as string;
const { config } = useConfig();
const [nodeData, setNodeData] = useState<NodeData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
// Use TanStack Query for neighbors data - only fetch if node is uplinked
// Use TanStack Query for node data
const {
data: nodeData,
isLoading: loading,
error: queryError
} = useNodeData({
publicKey: publicKey,
enabled: !!publicKey
});
// Use TanStack Query for neighbors data - fetch for all nodes
const {
data: neighbors = [],
isLoading: neighborsLoading
} = useNeighbors({
nodeId: publicKey,
lastSeen: config.lastSeen,
enabled: !!nodeData?.mqtt?.is_uplinked
enabled: !!publicKey
});
// Fetch node data only when publicKey changes
useEffect(() => {
if (!publicKey) return;
const fetchNodeData = async () => {
try {
setLoading(true);
setError(null);
setErrorCode(null);
// Fetch node data
const nodeResponse = await fetch(`/api/meshcore/node/${publicKey}`);
if (nodeResponse.status === 404) {
const errorData = await nodeResponse.json().catch(() => ({}));
setError(errorData.error || "Node not found");
setErrorCode(errorData.code || "NODE_NOT_FOUND");
return;
}
if (nodeResponse.status === 400) {
const errorData = await nodeResponse.json().catch(() => ({}));
setError(errorData.error || "Invalid request");
setErrorCode(errorData.code || "BAD_REQUEST");
return;
}
if (nodeResponse.status === 503) {
const errorData = await nodeResponse.json().catch(() => ({}));
setError(errorData.error || "Service temporarily unavailable");
setErrorCode(errorData.code || "SERVICE_UNAVAILABLE");
return;
}
if (!nodeResponse.ok) {
const errorData = await nodeResponse.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to fetch node data");
}
const nodeData = await nodeResponse.json();
setNodeData(nodeData);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setErrorCode("NETWORK_ERROR");
} finally {
setLoading(false);
}
};
fetchNodeData();
}, [publicKey]);
// Extract error information from TanStack Query error
const error = queryError?.error || null;
const errorCode = queryError?.code || null;
if (loading) {
@@ -354,6 +262,32 @@ export default function MeshcoreNodePage() {
{node.public_key}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">First Seen</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div className="space-y-1">
<div>
{moment.utc(node.first_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div className="text-gray-500 dark:text-gray-400">
{moment.utc(node.first_seen).local().fromNow()}
</div>
</div>
</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">
<div className="space-y-1">
<div>
{moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div className="text-gray-500 dark:text-gray-400">
{moment.utc(node.last_seen).local().fromNow()}
</div>
</div>
</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">
@@ -463,9 +397,8 @@ export default function MeshcoreNodePage() {
</div>
</div>
{/* Neighbors Section - Only show if MQTT uplink is connected */}
{mqtt.is_uplinked && (
<div className="mt-6 bg-white dark:bg-neutral-900 shadow rounded-lg">
{/* Neighbors Section - Show for all nodes */}
<div className="mt-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">
Neighbors ({neighborsLoading ? "..." : neighbors.length})
@@ -545,11 +478,8 @@ export default function MeshcoreNodePage() {
{neighbor.directions && neighbor.directions.length > 0 && (
<div className="flex items-center gap-1">
<span>Direction:</span>
{neighbor.directions.includes('incoming') && <span title="Incoming - This node hears the neighbor">📥</span>}
{neighbor.directions.includes('outgoing') && <span title="Outgoing - The neighbor hears this node">📤</span>}
{neighbor.directions.includes('incoming') && neighbor.directions.includes('outgoing') && (
<span className="text-green-600 dark:text-green-400"> Bidirectional</span>
)}
{neighbor.directions.includes('incoming') && <ArrowRightEndOnRectangleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" title="Incoming - This node hears the neighbor" />}
{neighbor.directions.includes('outgoing') && <ArrowRightStartOnRectangleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" title="Outgoing - The neighbor hears this node" />}
</div>
)}
</div>
@@ -559,7 +489,6 @@ export default function MeshcoreNodePage() {
)}
</div>
</div>
)}
</div>
</div>
);
+28 -3
View File
@@ -7,20 +7,25 @@ import SearchInput from '@/components/SearchInput';
import SearchResults from '@/components/SearchResults';
import RegionSelector from '@/components/RegionSelector';
import { LAST_SEEN_OPTIONS } from '@/components/ConfigContext';
import { useState, Suspense } from 'react';
import { useState, Suspense, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
function SearchPageContent() {
const { config } = useConfig();
const { query, setQuery, setLimit } = useSearchQuery();
const { query, setQuery, setLimit, setExact } = useSearchQuery();
const [showFilters, setShowFilters] = useState(false);
// Helper function to check if exact search is enabled
const isExactEnabled = query.exact === true || (typeof query.exact === 'string' && (query.exact === 'true' || query.exact === ''));
// Always use config values for region and lastSeen
const searchParams = {
query: query.q,
region: config.selectedRegion,
lastSeen: config.lastSeen,
limit: query.limit || 50,
exact: isExactEnabled,
};
const { data, isLoading, error } = useMeshcoreSearch({
@@ -77,7 +82,7 @@ function SearchPageContent() {
{showFilters && (
<div className="mt-4 p-4 bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Region Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -130,6 +135,26 @@ function SearchPageContent() {
<option value={200}>200 results</option>
</select>
</div>
{/* Exact Match Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Match Type
</label>
<div className="flex items-center">
<input
type="checkbox"
id="exact-match"
checked={isExactEnabled}
onChange={(e) => setExact(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="exact-match" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Exact match only
</label>
</div>
</div>
</div>
</div>
)}
+67 -135
View File
@@ -1,27 +1,15 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
import { useConfig } from "./ConfigContext";
import { decryptMeshcoreGroupMessage } from "../lib/meshcore";
import { getChannelIdFromKey } from "../lib/meshcore";
import ChatMessageItem, { ChatMessage } from "./ChatMessageItem";
import ChatMessageItem from "./ChatMessageItem";
import RefreshButton from "./RefreshButton";
import { buildApiUrl } from "../lib/api";
import RegionSelector from "./RegionSelector";
import { getRegionConfig } from "../lib/regions";
import { useChatMessages } from "../hooks/useChatMessages";
import { useIntersectionObserver } from "../hooks/useIntersectionObserver";
const PAGE_SIZE = 20;
function formatHex(hex: string): string {
// Add a space every 2 characters for readability
return hex.replace(/(.{2})/g, "$1 ").trim();
}
function formatLocalTime(utcString: string): string {
// Parse as UTC and display in local time
const utcDate = new Date(utcString + (utcString.endsWith("Z") ? "" : "Z"));
return utcDate.toLocaleString();
}
interface ChatBoxProps {
showAllMessagesTab?: boolean;
@@ -53,112 +41,53 @@ export default function ChatBox({
const [selectedTab, setSelectedTab] = useState(showAllMessagesTab ? 1 : 0);
const [minimized, setMinimized] = useState(!startExpanded); // Use startExpanded as default for minimized state
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [lastBefore, setLastBefore] = useState<string | undefined>(undefined);
const selectedKey = allTabs[selectedTab];
const channelId = selectedKey.isAllMessages
? undefined
: getChannelIdFromKey(selectedKey.privateKey).toUpperCase();
// Use the new chat messages hook
const {
messages,
loading,
hasMore,
loadMore,
isLoadingMore,
refresh,
isRefreshing
} = useChatMessages({
channelId,
region: config?.selectedRegion,
enabled: !minimized,
autoRefreshEnabled: !minimized,
});
// Only show tabs if more than one channel (or if we have all messages tab)
const showTabs = allTabs.length > 1;
const fetchMessages = useCallback(
async (before?: string, replace = false, after?: string) => {
if (!config?.selectedRegion) return;
setLoading(true);
try {
let url = `/api/chat?limit=${PAGE_SIZE}&region=${encodeURIComponent(
config.selectedRegion!
)}`;
if (channelId) url += `&channel_id=${channelId}`;
if (after) {
// Fetch newer messages using the after parameter
url += `&after=${encodeURIComponent(after)}`;
} else if (before) {
// Fetch older messages using before parameter
url += `&before=${encodeURIComponent(before)}`;
}
const res = await fetch(buildApiUrl(url));
const data = await res.json();
if (Array.isArray(data)) {
if (after) {
// Add newer messages to the beginning (most recent first)
if (data.length > 0) {
setMessages((prev) => [...data, ...prev]);
}
} else {
setMessages((prev) => (replace ? data : [...prev, ...data]));
setHasMore(data.length === PAGE_SIZE);
if (data.length > 0) {
setLastBefore(data[data.length - 1].ingest_timestamp);
}
}
} else {
// Only set hasMore to false if this is not an auto-refresh request
if (!after) {
setHasMore(false);
}
}
} catch (error) {
console.error("Load failed:", error);
} finally {
setLoading(false);
// Set up intersection observer for infinite scrolling
const loadMoreTriggerRef = useIntersectionObserver(
() => {
if (hasMore && !isLoadingMore && !loading) {
loadMore();
}
},
[channelId, config.selectedRegion]
{
threshold: 0.1,
rootMargin: '100px',
enabled: hasMore && !isLoadingMore && !loading
}
);
useEffect(() => {
if (!minimized) {
setMessages([]);
setHasMore(true);
setLastBefore(undefined);
fetchMessages(undefined, true);
}
}, [minimized, selectedTab, config?.selectedRegion, fetchMessages]);
// Auto-refresh effect
useEffect(() => {
if (!minimized && messages.length > 0) {
const interval = setInterval(() => {
// Auto-refresh should only fetch newer messages, not replace all
// Pass the most recent timestamp directly to avoid ref issues
const mostRecentTimestamp = messages[0].ingest_timestamp;
fetchMessages(undefined, false, mostRecentTimestamp);
}, 5000);
return () => clearInterval(interval);
}
}, [minimized, channelId, messages, fetchMessages]);
const handleLoadMore = () => {
if (lastBefore) {
fetchMessages(lastBefore);
}
};
const handleRefresh = () => {
setMessages([]);
setHasMore(true);
setLastBefore(undefined);
fetchMessages(undefined, true);
refresh();
};
const LoadMoreButton = () => (
<button
className="w-full py-2 bg-gray-100 dark:bg-neutral-800 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-neutral-700"
onClick={handleLoadMore}
disabled={loading}
>
{loading ? "Loading..." : "Load more"}
</button>
const LoadingIndicator = () => (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-700 dark:border-gray-200"></div>
</div>
);
return (
@@ -187,7 +116,7 @@ export default function ChatBox({
{!minimized && config?.selectedRegion && (
<RefreshButton
onClick={handleRefresh}
loading={loading}
loading={isRefreshing}
small={true}
title="Refresh chat messages"
ariaLabel="Refresh chat messages"
@@ -231,39 +160,42 @@ export default function ChatBox({
</div>
)}
<div
className={`flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 ${
startExpanded ? "" : "flex flex-col-reverse"
}`}
>
<div className={`p-4 ${startExpanded ? "flex flex-col gap-2" : "flex flex-col gap-2"}`}>
{messages.length === 0 && !loading && (
<div className={`text-gray-400 text-center ${startExpanded ? "py-8" : "mt-8"}`}>
No chat messages found.
</div>
)}
{hasMore && !startExpanded && <LoadMoreButton />}
{(startExpanded ? messages : messages.toReversed()).map((msg, i) => (
<ChatMessageItem
key={`${msg.ingest_timestamp}-${msg.origin_key_path_array?.length || 0}`}
msg={msg}
showErrorRow={selectedKey.isAllMessages}
/>
))}
{hasMore && startExpanded && <LoadMoreButton />}
</div>
</div>
<div
className={`flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 ${
startExpanded ? "" : "flex flex-col-reverse"
}`}
>
<div className={`p-4 ${startExpanded ? "flex flex-col gap-2" : "flex flex-col gap-2"}`}>
{messages.length === 0 && !loading && (
<div className={`text-gray-400 text-center ${startExpanded ? "py-8" : "mt-8"}`}>
No chat messages found.
</div>
)}
{/* Messages */}
{(startExpanded ? messages : messages.toReversed()).map((msg, i) => (
<ChatMessageItem
key={`${msg.ingest_timestamp}-${msg.origin_key_path_array?.length || 0}`}
msg={msg}
showErrorRow={selectedKey.isAllMessages}
/>
))}
{/* Loading indicator */}
{isLoadingMore && <LoadingIndicator />}
{/* Load more trigger always at the bottom */}
{hasMore && (
<div ref={loadMoreTriggerRef} className="h-2" />
)}
</div>
</div>
</>
)}
{!minimized && !config?.selectedRegion && (
<div className="p-4 flex flex-col rounded-lg overflow-scroll">
<RegionSelector
onRegionSelected={() => {
setMessages([]);
setHasMore(true);
setLastBefore(undefined);
fetchMessages(undefined, true);
}}
className="w-full"
/>
</div>
+8 -1
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useConfig } from "./ConfigContext";
import { decryptMeshcoreGroupMessage } from "../lib/meshcore";
import PathVisualization, { PathData } from "./PathVisualization";
import NodeLinkWithHover from "./NodeLinkWithHover";
export interface ChatMessage {
ingest_timestamp: string;
@@ -115,7 +116,13 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
</div>
<div className="break-words whitespace-pre-wrap">
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
{parsed.sender ? (
<NodeLinkWithHover
nodeName={parsed.sender}
>
{parsed.sender}
</NodeLinkWithHover>
) : null}
{parsed.sender && ": "}
<span>{linkifyText(parsed.text)}</span>
</div>
+16 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
interface ContactQRCodeProps {
@@ -12,15 +12,17 @@ interface ContactQRCodeProps {
export default function ContactQRCode({ name, publicKey, type, size = 200 }: ContactQRCodeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [contactUrl, setContactUrl] = useState<string>("");
useEffect(() => {
if (!canvasRef.current) return;
const generateQR = async () => {
try {
const contactUrl = `meshcore://contact/add?name=${encodeURIComponent(name)}&public_key=${encodeURIComponent(publicKey)}&type=${type}`;
const url = `meshcore://contact/add?name=${encodeURIComponent(name)}&public_key=${encodeURIComponent(publicKey)}&type=${type}`;
setContactUrl(url);
await QRCode.toCanvas(canvasRef.current, contactUrl, {
await QRCode.toCanvas(canvasRef.current, url, {
width: size,
margin: 2,
color: {
@@ -38,10 +40,17 @@ export default function ContactQRCode({ name, publicKey, type, size = 200 }: Con
return (
<div className="flex flex-col items-center">
<canvas
ref={canvasRef}
className="border border-gray-200 dark:border-gray-700 rounded-lg"
/>
<a
href={contactUrl}
rel="noopener noreferrer"
className="inline-block hover:opacity-80 transition-opacity"
title="Click to open meshcore contact link"
>
<canvas
ref={canvasRef}
className="border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer"
/>
</a>
</div>
);
}
+14 -4
View File
@@ -7,6 +7,8 @@ import { NodePosition } from '../types/map';
interface NodeMarkerProps {
node: NodePosition;
showNodeNames?: boolean;
isSelected?: boolean;
isLoadingNeighbors?: boolean;
}
interface ClusterMarkerProps {
@@ -18,14 +20,22 @@ interface PopupContentProps {
}
// Individual node marker component
export function NodeMarker({ node, showNodeNames = true }: NodeMarkerProps) {
export function NodeMarker({ node, showNodeNames = true, isSelected = false, isLoadingNeighbors = false }: NodeMarkerProps) {
const getMarkerClass = () => {
let baseClass = "custom-node-marker";
if (node.type === "meshtastic") {
return "custom-node-marker custom-node-marker--green";
baseClass += " custom-node-marker--green";
} else if (node.type === "meshcore") {
return "custom-node-marker custom-node-marker--blue custom-node-marker--top";
baseClass += " custom-node-marker--blue custom-node-marker--top";
}
return "custom-node-marker";
// Only add loading class when actually loading neighbors
if (isLoadingNeighbors) {
baseClass += " custom-node-marker--loading";
}
return baseClass;
};
return (
+41 -9
View File
@@ -26,6 +26,7 @@ type ClusteredMarkersProps = {
nodes: NodePosition[];
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
};
// Individual marker component
@@ -33,12 +34,14 @@ function IndividualMarker({
node,
showNodeNames,
selectedNodeId,
onNodeClick
onNodeClick,
isLoadingNeighbors = false
}: {
node: NodePosition;
showNodeNames: boolean;
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
}) {
const map = useMap();
const markerRef = useRef<L.Marker | null>(null);
@@ -52,11 +55,19 @@ function IndividualMarker({
useEffect(() => {
if (!map) return;
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
@@ -78,7 +89,7 @@ function IndividualMarker({
map.removeLayer(markerRef.current);
}
};
}, [map, node, showNodeNames]);
}, [map, node, showNodeNames, selectedNodeId, isLoadingNeighbors]);
// Update marker when node data changes
useEffect(() => {
@@ -89,16 +100,24 @@ function IndividualMarker({
}
// Update icon and popup
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
markerRef.current.setIcon(icon);
markerRef.current.getPopup()?.setContent(renderToString(<PopupContent node={node} />));
}
}, [node, showNodeNames]);
}, [node, showNodeNames, selectedNodeId, isLoadingNeighbors]);
return null;
}
@@ -108,12 +127,14 @@ function ClusteredMarkersGroup({
nodes,
showNodeNames,
selectedNodeId,
onNodeClick
onNodeClick,
isLoadingNeighbors = false
}: {
nodes: NodePosition[];
showNodeNames: boolean;
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
}) {
const map = useMap();
const clusterGroupRef = useRef<any>(null);
@@ -143,11 +164,19 @@ function ClusteredMarkersGroup({
});
nodes.forEach((node: NodePosition) => {
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
@@ -172,12 +201,12 @@ function ClusteredMarkersGroup({
map.removeLayer(clusterGroupRef.current);
}
};
}, [map, nodes, showNodeNames]);
}, [map, nodes, showNodeNames, selectedNodeId, isLoadingNeighbors]);
return null;
}
function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarkersProps) {
function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick, isLoadingNeighbors = false }: ClusteredMarkersProps) {
const configResult = useConfig();
const config = configResult?.config;
const showNodeNames = config?.showNodeNames !== false;
@@ -193,6 +222,7 @@ function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarke
showNodeNames={showNodeNames}
selectedNodeId={selectedNodeId}
onNodeClick={onNodeClick}
isLoadingNeighbors={isLoadingNeighbors}
/>
))}
</>
@@ -205,6 +235,7 @@ function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarke
showNodeNames={showNodeNames}
selectedNodeId={selectedNodeId}
onNodeClick={onNodeClick}
isLoadingNeighbors={isLoadingNeighbors}
/>
);
}
@@ -502,6 +533,7 @@ export default function MapView() {
nodes={nodePositions}
selectedNodeId={selectedNodeId}
onNodeClick={handleNodeClick}
isLoadingNeighbors={neighborsLoading}
/>
<NeighborLines
selectedNodeId={selectedNodeId}
+2
View File
@@ -27,11 +27,13 @@ export default function MapWithChat({ nodePositions }: MapWithChatProps) {
>
<div className="flex-1 relative">
<MapView />
{/* ChatBox temporarily disabled - infinite scroll is broken in reverse view
<div className="absolute bottom-6 right-6 z-30">
<div className="w-80">
<ChatBox showAllMessagesTab={false} startExpanded={false} className="w-full" />
</div>
</div>
*/}
</div>
</div>
);
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import moment from 'moment';
import { formatPublicKey } from '@/lib/meshcore';
import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch';
export interface NodeCardData {
public_key: string;
node_name: string | null;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
last_seen: string;
topic?: string;
broker?: string;
}
interface NodeCardProps {
node: NodeCardData | MeshcoreSearchResult;
className?: string;
showTopicInfo?: boolean;
}
export default function NodeCard({ node, className = "", showTopicInfo = true }: NodeCardProps) {
const hasLocation = node.has_location === 1;
const isRepeater = node.is_repeater === 1;
const isChatNode = node.is_chat_node === 1;
const isRoomServer = node.is_room_server === 1;
return (
<Link
href={`/meshcore/node/${node.public_key}`}
className={`block bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md dark:hover:shadow-lg transition-shadow ${className}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
{node.node_name || 'Unnamed Node'}
</h4>
<div className="flex items-center gap-1">
{isRepeater && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
<WifiIcon className="h-3 w-3 mr-1" />
Repeater
</span>
)}
{isChatNode && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<ChatBubbleLeftRightIcon className="h-3 w-3 mr-1" />
Chat
</span>
)}
{isRoomServer && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200">
<ServerIcon className="h-3 w-3 mr-1" />
Room Server
</span>
)}
</div>
</div>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
{hasLocation && node.latitude && node.longitude && (
<div className="flex items-center gap-1">
<MapPinIcon className="h-4 w-4" />
<span>
{node.latitude.toFixed(4)}, {node.longitude.toFixed(4)}
</span>
</div>
)}
<div className="flex items-center gap-4">
<span>Last seen: {moment.utc(node.last_seen).local().fromNow()}</span>
<span className="text-xs font-mono text-gray-500 dark:text-gray-500">
{formatPublicKey(node.public_key)}
</span>
</div>
{showTopicInfo && node.topic && node.broker && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Topic: {node.topic} Broker: {node.broker.split('://')[1]}
</div>
)}
</div>
</div>
</div>
</Link>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Popover } from 'react-tiny-popover';
import { useMeshcoreSearch } from '@/hooks/useMeshcoreSearch';
import { useConfig } from '@/components/ConfigContext';
import NodeCard from '@/components/NodeCard';
interface NodeLinkWithHoverProps {
nodeName: string;
children: React.ReactNode;
}
export default function NodeLinkWithHover({
nodeName,
children
}: NodeLinkWithHoverProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isWaitingForSearch, setIsWaitingForSearch] = useState(false);
const { config } = useConfig();
const router = useRouter();
// Search query - enabled immediately when component mounts
const {
data: searchData,
isLoading: isSearchLoading,
error: searchError
} = useMeshcoreSearch({
query: nodeName,
region: config.selectedRegion,
lastSeen: config.lastSeen,
limit: 10,
exact: true,
enabled: !!nodeName
});
const searchResults = searchData?.results || [];
const foundNode = searchResults.length === 1 ? searchResults[0] : null;
const noResultsFound = searchData && searchResults.length === 0;
// Determine the href for the Link component - handles all cases
const linkHref = (() => {
// If search is loading or hasn't completed, use placeholder
if (isSearchLoading) return "#";
// If exactly one result found, link directly to node
if (foundNode) return `/meshcore/node/${foundNode.public_key}`;
// If no results or multiple results, link to search page
return `/search?q=${encodeURIComponent(nodeName)}&exact`;
})();
// Handle click behavior
const handleClick = (e: React.MouseEvent) => {
// If href is "#", prevent navigation and handle waiting
if (linkHref === "#") {
e.preventDefault();
// If search is in progress, wait for it
if (isSearchLoading) {
setIsWaitingForSearch(true);
}
}
// Otherwise, let Next.js Link handle the navigation
};
// Effect to handle navigation after search completes
useEffect(() => {
// Only trigger navigation when we were waiting AND search just completed
if (isWaitingForSearch && !isSearchLoading && searchData) {
setIsWaitingForSearch(false);
// Calculate navigation URL directly here since linkHref might still be "#"
const navigationUrl = foundNode
? `/meshcore/node/${foundNode.public_key}`
: `/search?q=${encodeURIComponent(nodeName)}&exact`;
router.push(navigationUrl);
}
}, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData]);
// Popover content component
const PopoverContent = () => {
return (
<div className="relative bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-lg overflow-hidden">
{isSearchLoading ? (
<div className="p-4 text-center w-80">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600 dark:text-gray-400">Searching for {nodeName}...</p>
</div>
) : searchError ? (
<div className="p-4 text-center w-80">
<p className="text-sm text-red-600 dark:text-red-400">Search error</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Click to search manually</p>
</div>
) : foundNode ? (
<NodeCard
node={foundNode}
className="border-0 shadow-none hover:shadow-none"
showTopicInfo={false}
/>
) : searchResults.length === 0 ? (
<div className="p-4 text-center w-80">
<p className="text-sm text-gray-600 dark:text-gray-400">No node found for &quot;{nodeName}&quot;</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">The node may need to advert to appear</p>
</div>
) : (
<div className="p-4 text-center w-80">
<p className="text-sm text-gray-600 dark:text-gray-400">
{searchResults.length} nodes found for &quot;{nodeName}&quot;
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Click to see all results</p>
</div>
)}
{isWaitingForSearch && (
<div className="absolute inset-0 bg-white/80 dark:bg-neutral-800/80 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600 dark:text-gray-400">Navigating...</p>
</div>
</div>
)}
</div>
);
};
// If search completed and no results found, render gray text instead of link
if (noResultsFound) {
return (
<Popover
isOpen={isPopoverOpen}
positions={['bottom', 'top', 'left', 'right']}
padding={8}
content={<PopoverContent />}
onClickOutside={() => setIsPopoverOpen(false)}
containerStyle={{ zIndex: "1000" }}
>
<span
className="inline-block font-bold text-gray-500 dark:text-gray-400 cursor-default"
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
{children}
</span>
</Popover>
);
}
return (
<Popover
isOpen={isPopoverOpen}
positions={['bottom', 'top', 'left', 'right']}
padding={8}
content={<PopoverContent />}
onClickOutside={() => setIsPopoverOpen(false)}
containerStyle={{ zIndex: "1000" }}
>
<Link
href={linkHref}
className="inline-block font-bold text-blue-800 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-200 hover:underline transition-colors"
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
onClick={handleClick}
>
{children}
</Link>
</Popover>
);
}
+128 -31
View File
@@ -6,6 +6,8 @@ import Link from "next/link";
import Tree from 'react-d3-tree';
import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline";
import PathDisplay from "./PathDisplay";
import { useMeshcoreSearches } from "../hooks/useMeshcoreSearch";
import type { MeshcoreSearchResult } from "../hooks/useMeshcoreSearch";
export interface PathData {
origin: string;
@@ -32,6 +34,7 @@ interface PathVisualizationProps {
initiatingNodeKey?: string;
}
export default function PathVisualization({
paths,
title = "Paths",
@@ -102,6 +105,67 @@ export default function PathVisualization({
return buildTree();
}, [showGraph, paths, pathsCount, initiatingNodeKey]);
// Extract unique prefixes from tree data for name lookups
const uniquePrefixes = useMemo(() => {
if (!treeData) return [];
const prefixes = new Set<string>();
const extractPrefixes = (node: TreeNode) => {
prefixes.add(node.name);
node.children?.forEach(extractPrefixes);
};
extractPrefixes(treeData);
return Array.from(prefixes);
}, [treeData]);
// Use the new useMeshcoreSearches hook to handle multiple prefix searches
// Filter out "??" prefix and only search for valid hex prefixes
const searches = useMemo(() =>
uniquePrefixes
.filter(prefix => prefix !== "??") // Don't search for placeholder prefix
.map(prefix => ({
query: prefix,
exact: false,
limit: 20,
is_repeater: true, // Filter for repeaters only
enabled: showGraph && prefix.length > 0
}))
, [uniquePrefixes, showGraph]);
const searchResults = useMeshcoreSearches({ searches });
// Create mapping from prefix to node data (name + public key)
const prefixToNodes = useMemo(() => {
const mapping = new Map<string, Array<{ name: string; publicKey: string }>>();
// Create searchable prefixes (excluding "??")
const searchablePrefixes = uniquePrefixes.filter(prefix => prefix !== "??");
searchablePrefixes.forEach((prefix, index) => {
const searchResult = searchResults[index];
if (searchResult?.data?.results) {
const matchingNodes = searchResult.data.results
.filter(result => result.public_key.toLowerCase().startsWith(prefix.toLowerCase()) && result.node_name)
.map(result => ({
name: result.node_name,
publicKey: result.public_key
}))
.filter(node => node.name.length > 0);
if (matchingNodes.length > 0) {
mapping.set(prefix, matchingNodes);
}
}
});
return mapping;
}, [searchResults, uniquePrefixes]);
// Fixed node spacing optimized for ~3 lines of text
const fixedNodeSize = { x: 140, y: 100 };
const handleToggle = useCallback(() => {
setExpanded(prev => !prev);
}, []);
@@ -134,6 +198,65 @@ export default function PathVisualization({
</div>
), [paths]);
// Memoize the render function to prevent unnecessary re-renders
const renderCustomNodeElement = useCallback(({ nodeDatum, toggleNode }: any) => {
const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
const isRoot = nodeDatum.name === rootName;
// Check if this node represents an origin pubkey (final 2-char hex from pubkey)
const isOriginPubkey = paths.some(({ pubkey }) => {
const pubkeyPrefix = pubkey.substring(0, 2);
return nodeDatum.name === pubkeyPrefix;
});
// Get node data for this prefix
const nodeData = prefixToNodes.get(nodeDatum.name) || [];
const isResolved = nodeData.length > 0;
return (
<g key={`node-${nodeDatum.name}`}>
{/* Use same circle style for all nodes */}
<circle
r={15}
fill={isRoot ? "#3b82f6" : "#6b7280"}
stroke={isOriginPubkey && !isRoot ? "#10b981" : "none"}
strokeWidth={isOriginPubkey && !isRoot ? 2 : 0}
/>
{/* Hex prefix inside circle */}
<text
textAnchor="middle"
y="5"
style={{ fontSize: "10px", fill: "white", fontWeight: "bold", stroke: "none" }}
>
{nodeDatum.name}
</text>
{/* Show all node names below circle for resolved prefixes - clickable */}
{isResolved && nodeData.map((node, index) => {
// Calculate dynamic width based on text length (approximate 6px per character + padding)
const estimatedWidth = Math.max(60, node.name.length * 8 + 20);
return (
<foreignObject
key={index}
x={-estimatedWidth / 2} // Center the link horizontally
y={24 + (index * 12)} // Increased gap from circle to first name
width={estimatedWidth}
height={16}
>
<Link
href={`/meshcore/node/${node.publicKey}`}
className="block text-center font-bold hover:opacity-80 cursor-pointer whitespace-nowrap px-1 py-0.5 text-xs text-blue-600 dark:text-blue-300"
>
{node.name}
</Link>
</foreignObject>
);
})}
</g>
);
}, [initiatingNodeKey, paths, prefixToNodes]);
const GraphView = useCallback(() => {
if (!showGraph || pathsCount === 0 || !treeData) return null;
@@ -145,36 +268,10 @@ export default function PathVisualization({
pathFunc="step"
translate={{ x: graphFullscreen ? 300 : 200, y: graphFullscreen ? 80 : 50 }}
separation={{ siblings: 1.2, nonSiblings: 1.5 }}
nodeSize={{ x: 60, y: 60 }}
nodeSize={fixedNodeSize}
zoomable={true}
draggable={true}
renderCustomNodeElement={({ nodeDatum, toggleNode }) => {
const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
const isRoot = nodeDatum.name === rootName;
// Check if this node represents an origin pubkey (final 2-char hex from pubkey)
const isOriginPubkey = paths.some(({ pubkey }) => {
const pubkeyPrefix = pubkey.substring(0, 2);
return nodeDatum.name === pubkeyPrefix;
});
return (
<g key={`node-${nodeDatum.name}`}>
<circle
r={15}
fill={isRoot ? "#3b82f6" : "#6b7280"}
stroke={isOriginPubkey && !isRoot ? "#10b981" : "none"}
strokeWidth={isOriginPubkey && !isRoot ? 2 : 0}
/>
<text
textAnchor="middle"
y="5"
style={{ fontSize: "12px", fill: "white", fontWeight: "bold", stroke: "none" }}
>
{nodeDatum.name}
</text>
</g>
);
}}
renderCustomNodeElement={renderCustomNodeElement}
/>
);
@@ -194,7 +291,7 @@ export default function PathVisualization({
</button>
</div>
<div className="flex-1 overflow-hidden">
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-600">
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded bg-neutral-200 dark:bg-neutral-800">
{renderTree()}
</div>
</div>
@@ -217,12 +314,12 @@ export default function PathVisualization({
<ArrowsPointingOutIcon className="w-4 h-4" />
</button>
</div>
<div className="w-full h-64 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-600">
<div className="w-full h-64 border border-gray-200 dark:border-gray-700 rounded bg-neutral-200 dark:bg-neutral-800">
{renderTree()}
</div>
</div>
);
}, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, paths, initiatingNodeKey]);
}, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, renderCustomNodeElement]);
if (!showDropdown) {
return (
+3 -70
View File
@@ -1,9 +1,8 @@
"use client";
import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch';
import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import moment from 'moment';
import { WifiIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import NodeCard from '@/components/NodeCard';
interface SearchResultsProps {
results: MeshcoreSearchResult[];
@@ -85,76 +84,10 @@ export default function SearchResults({ results, isLoading, error, query, total
<div className="space-y-3">
{results.map((node) => (
<SearchResultItem key={node.public_key} node={node} />
<NodeCard key={node.public_key} node={node} />
))}
</div>
</div>
);
}
function SearchResultItem({ node }: { node: MeshcoreSearchResult }) {
const lastSeen = new Date(node.last_seen);
const hasLocation = node.has_location === 1;
const isRepeater = node.is_repeater === 1;
const isChatNode = node.is_chat_node === 1;
const isRoomServer = node.is_room_server === 1;
return (
<Link
href={`/meshcore/node/${node.public_key}`}
className="block bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md dark:hover:shadow-lg transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
{node.node_name || 'Unnamed Node'}
</h4>
<div className="flex items-center gap-1">
{isRepeater && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
<WifiIcon className="h-3 w-3 mr-1" />
Repeater
</span>
)}
{isChatNode && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<ChatBubbleLeftRightIcon className="h-3 w-3 mr-1" />
Chat
</span>
)}
{isRoomServer && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200">
<ServerIcon className="h-3 w-3 mr-1" />
Room Server
</span>
)}
</div>
</div>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
{hasLocation && node.latitude && node.longitude && (
<div className="flex items-center gap-1">
<MapPinIcon className="h-4 w-4" />
<span>
{node.latitude.toFixed(4)}, {node.longitude.toFixed(4)}
</span>
</div>
)}
<div className="flex items-center gap-4">
<span>Last seen: {moment(lastSeen).fromNow()}</span>
<span className="text-xs font-mono text-gray-500 dark:text-gray-500">
{node.public_key.substring(0, 8)}...
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-500">
Topic: {node.topic} Broker: {node.broker.split('://')[1]}
</div>
</div>
</div>
</div>
</Link>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { buildApiUrl } from '../lib/api';
import { ChatMessage } from '../components/ChatMessageItem';
interface ChatMessagesParams {
channelId?: string;
region?: string;
enabled?: boolean;
autoRefreshEnabled?: boolean;
}
interface ChatMessagesPage {
messages: ChatMessage[];
hasMore: boolean;
oldestTimestamp?: string;
}
const PAGE_SIZE = 20;
export function useChatMessages({
channelId,
region,
enabled = true,
autoRefreshEnabled = true,
}: ChatMessagesParams) {
const queryClient = useQueryClient();
// Build base query key
const baseQueryKey = useMemo(() =>
['chat-messages', channelId, region] as const,
[channelId, region]
);
// Main infinite query for loading messages with pagination
const messagesQuery = useInfiniteQuery({
queryKey: baseQueryKey,
queryFn: async ({ pageParam, signal }): Promise<ChatMessagesPage> => {
if (!region) {
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 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,
};
},
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.oldestTimestamp : undefined;
},
initialPageParam: undefined as string | undefined,
enabled: enabled && !!region,
staleTime: 10 * 1000, // 10 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
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
useEffect(() => {
if (autoRefreshQuery.data && autoRefreshQuery.data.length > 0) {
queryClient.setQueryData(baseQueryKey, (oldData: any) => {
if (!oldData?.pages?.[0]) return oldData;
const newMessages = autoRefreshQuery.data;
const firstPage = oldData.pages[0];
// Add new messages to the beginning of the first page
const updatedFirstPage = {
...firstPage,
messages: [...newMessages, ...firstPage.messages]
};
return {
...oldData,
pages: [updatedFirstPage, ...oldData.pages.slice(1)]
};
});
}
}, [autoRefreshQuery.data, 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;
return {
messages: allMessages,
loading: messagesQuery.isLoading,
error: messagesQuery.error || autoRefreshQuery.error,
hasMore: hasNextPage,
loadMore: messagesQuery.fetchNextPage,
isLoadingMore: messagesQuery.isFetchingNextPage,
refresh: () => {
queryClient.invalidateQueries({ queryKey: baseQueryKey });
},
isRefreshing: messagesQuery.isRefetching,
};
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef, useCallback } from 'react';
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
enabled?: boolean;
}
export function useIntersectionObserver(
callback: () => void,
options: UseIntersectionObserverOptions = {}
) {
const {
threshold = 0.1,
rootMargin = '100px',
enabled = true
} = options;
const targetRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const entry = entries[0];
if (entry.isIntersecting && enabled) {
callback();
}
},
[callback, enabled]
);
useEffect(() => {
const target = targetRef.current;
if (!target || !enabled) return;
// Clean up existing observer
if (observerRef.current) {
observerRef.current.disconnect();
}
// Create new observer
observerRef.current = new IntersectionObserver(handleIntersection, {
threshold,
rootMargin,
});
observerRef.current.observe(target);
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [handleIntersection, threshold, rootMargin, enabled]);
return targetRef;
}
+170 -32
View File
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueries } from '@tanstack/react-query';
import { create, windowScheduler, indexedResolver } from '@yornaath/batshit';
import { buildApiUrl } from '../lib/api';
import { useMemo } from 'react';
export interface MeshcoreSearchResult {
public_key: string;
@@ -20,17 +22,72 @@ export interface MeshcoreSearchResult {
export interface MeshcoreSearchResponse {
results: MeshcoreSearchResult[];
total: number;
query: string;
region: string | null;
lastSeen: string | null;
limit: number;
}
// Search query parameters
export interface SearchQuery {
query?: string;
region?: string;
lastSeen?: number | null;
limit?: number;
exact?: boolean;
is_repeater?: boolean;
}
// Create batcher using batshit with simple index-based resolver
const searchBatcher = create({
fetcher: async (queries: SearchQuery[]) => {
const normalizedQueries = queries.map(q => ({
query: q.query?.trim() || "",
region: q.region || undefined,
lastSeen: q.lastSeen !== null && q.lastSeen !== undefined ? q.lastSeen : undefined,
limit: q.limit || 50,
exact: q.exact || false,
is_repeater: q.is_repeater
}));
// 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
return {
results: batchResponse.results || [],
queries: queries
};
},
resolver: (batchData: {results: MeshcoreSearchResult[][], queries: SearchQuery[]}, query: SearchQuery) => {
const index = batchData.queries.findIndex(q => JSON.stringify(q) === JSON.stringify(query));
return batchData.results[index] || [];
},
scheduler: windowScheduler(100)
});
// Hook parameters
interface UseMeshcoreSearchParams {
query: string;
region?: string;
lastSeen?: number | null;
limit?: number;
exact?: boolean;
is_repeater?: boolean;
enabled?: boolean;
}
@@ -39,41 +96,122 @@ export function useMeshcoreSearch({
region,
lastSeen,
limit = 50,
exact = false,
is_repeater,
enabled = true
}: UseMeshcoreSearchParams) {
return useQuery({
queryKey: ['meshcore-search', query, region, lastSeen, limit],
queryFn: async ({ signal }): Promise<MeshcoreSearchResponse> => {
const params = new URLSearchParams();
if (query.trim()) {
params.append('q', query.trim());
}
if (region) {
params.append('region', region);
}
if (lastSeen !== null && lastSeen !== undefined) {
params.append('lastSeen', lastSeen.toString());
}
if (limit !== 50) {
params.append('limit', limit.toString());
}
// Stabilize the search query object to prevent unnecessary re-renders
const trimmedQuery = query.trim();
const searchQuery: SearchQuery = useMemo(() => ({
query: trimmedQuery,
region,
lastSeen,
limit,
exact,
is_repeater
}), [trimmedQuery, region, lastSeen, limit, exact, is_repeater]);
const url = `/api/meshcore/search${params.toString() ? `?${params.toString()}` : ''}`;
return useQuery({
queryKey: ['meshcore-search', searchQuery.query, region, lastSeen, limit, exact, is_repeater],
queryFn: async ({ signal }): Promise<MeshcoreSearchResponse> => {
// Set up cancellation handler
const handleAbort = () => {
const abortController = (searchBatcher as any)._currentAbortController;
if (abortController) {
abortController.abort();
}
};
const response = await fetch(buildApiUrl(url), {
signal, // Use the AbortSignal from TanStack Query
});
signal?.addEventListener('abort', handleAbort);
if (!response.ok) {
throw new Error(`Failed to search meshcore nodes: ${response.statusText}`);
try {
const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || [];
return {
results: queryResults,
total: queryResults.length
};
} finally {
signal?.removeEventListener('abort', handleAbort);
}
return response.json();
},
enabled: enabled && query.trim().length > 0,
staleTime: 30 * 1000, // 30 seconds - shorter for search results
gcTime: 5 * 60 * 1000, // 5 minutes
staleTime: 1000, // Reduce stale time to be more responsive to typing
gcTime: 30 * 1000, // Reduce garbage collection time
retry: 1,
refetchOnWindowFocus: false, // Prevent duplicate requests on focus
});
}
// Hook for multiple searches using useQueries
interface UseMeshcoreSearchesParams {
searches: Array<{
query: string;
region?: string;
lastSeen?: number | null;
limit?: number;
exact?: boolean;
is_repeater?: boolean;
enabled?: boolean;
}>;
}
export function useMeshcoreSearches({ searches }: UseMeshcoreSearchesParams) {
// Create query configurations for useQueries
const queryConfigs = useMemo(() =>
searches.map((searchParams, index) => {
const {
query,
region,
lastSeen,
limit = 50,
exact = false,
is_repeater,
enabled = true
} = searchParams;
const trimmedQuery = query.trim();
const searchQuery: SearchQuery = {
query: trimmedQuery,
region,
lastSeen,
limit,
exact,
is_repeater
};
return {
queryKey: ['meshcore-search-batch', trimmedQuery, region, lastSeen, limit, exact, is_repeater],
queryFn: async ({ signal }: { signal?: AbortSignal }): Promise<MeshcoreSearchResponse> => {
// Set up cancellation handler
const handleAbort = () => {
const abortController = (searchBatcher as any)._currentAbortController;
if (abortController) {
abortController.abort();
}
};
signal?.addEventListener('abort', handleAbort);
try {
const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || [];
return {
results: queryResults,
total: queryResults.length
};
} finally {
signal?.removeEventListener('abort', handleAbort);
}
},
enabled: enabled && trimmedQuery.length > 0,
staleTime: 1000,
gcTime: 30 * 1000,
retry: 1,
refetchOnWindowFocus: false,
};
})
, [searches]);
return useQueries({
queries: queryConfigs
});
}
+121
View File
@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query';
import { buildApiUrl } from '../lib/api';
export interface NodeInfo {
public_key: string;
node_name: string;
latitude: number | null;
longitude: number | null;
has_location: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_name: number;
first_seen: string;
last_seen: string;
}
export interface Advert {
group_id: number;
origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples
advert_count: number;
earliest_timestamp: string;
latest_timestamp: string;
latitude: number | null;
longitude: number | null;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_location: number;
}
export interface LocationHistory {
mesh_timestamp: string;
latitude: number;
longitude: number;
}
export interface MqttTopic {
topic: string;
broker: string;
last_packet_time: string;
is_recent: boolean;
}
export interface MqttInfo {
is_uplinked: boolean;
has_packets: boolean;
topics: MqttTopic[];
}
export interface NodeData {
node: NodeInfo;
recentAdverts: Advert[];
locationHistory: LocationHistory[];
mqtt: MqttInfo;
}
export interface NodeError {
error: string;
code: string;
publicKey?: string;
}
interface UseNodeDataParams {
publicKey: string | null;
limit?: number;
enabled?: boolean;
}
export function useNodeData({ publicKey, limit = 50, enabled = true }: UseNodeDataParams) {
return useQuery<NodeData, NodeError>({
queryKey: ['node-data', publicKey, limit],
queryFn: async (): Promise<NodeData> => {
if (!publicKey) {
throw { error: "Public key is required", code: "MISSING_PUBLIC_KEY" } as NodeError;
}
const params = new URLSearchParams();
if (limit !== 50) {
params.append('limit', limit.toString());
}
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;
if (status && status >= 400 && status < 500) {
return false;
}
// Retry up to 1 time for server errors
return failureCount < 1;
},
});
}
+3 -1
View File
@@ -106,15 +106,17 @@ export function useQueryParams<T extends Record<string, any>>(defaultValues: T =
export interface SearchQuery {
q: string;
limit?: number;
exact?: boolean;
}
export function useSearchQuery() {
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50 });
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50, exact: false });
return {
query,
setQuery: (q: string) => setParam('q', q),
setLimit: (limit: number) => setParam('limit', limit),
setExact: (exact: boolean) => setParam('exact', exact),
updateQuery: (updates: Partial<SearchQuery>) => {
Object.entries(updates).forEach(([key, value]) => {
setParam(key as keyof SearchQuery, value as any);
+153 -63
View File
@@ -366,80 +366,147 @@ export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: stri
}
}
export async function searchMeshcoreNodes({
query: searchQuery,
region,
lastSeen,
limit = 50
}: {
query?: string;
region?: string;
interface SearchQuery {
query?: string;
region?: string;
lastSeen?: string | null;
limit?: number;
} = {}) {
limit?: number;
exact?: boolean;
is_repeater?: boolean;
}
export async function searchMeshcoreNodes(searchParams: SearchQuery | SearchQuery[] = {}) {
try {
let where = [];
const params: Record<string, any> = { limit };
// Normalize input to array format
const queries = Array.isArray(searchParams) ? searchParams : [searchParams];
// Add search conditions
if (searchQuery && searchQuery.trim()) {
const trimmedQuery = searchQuery.trim();
// If no queries or empty array, return empty results
if (queries.length === 0) {
return [];
}
// Build individual query parts
const queryParts: string[] = [];
const allParams: Record<string, any> = {};
queries.forEach((searchQuery, index) => {
const {
query: searchString,
region,
lastSeen,
limit = 50,
exact = false,
is_repeater
} = searchQuery;
// Check if it looks like a public key (hex string)
if (/^[0-9A-Fa-f]+$/.test(trimmedQuery)) {
// Search by public key prefix
where.push('public_key LIKE {publicKeyPattern:String}');
params.publicKeyPattern = `${trimmedQuery.toUpperCase()}%`;
} else {
// Search by node name (case insensitive, anywhere in the name)
where.push('lower(node_name) LIKE {namePattern:String}');
params.namePattern = `%${trimmedQuery.toLowerCase()}%`;
const where: string[] = [];
const queryParams: Record<string, any> = {};
// Add search conditions
if (searchString && searchString.trim()) {
const trimmedQuery = searchString.trim();
// Check if it looks like a public key (hex string)
if (/^[0-9A-Fa-f]+$/.test(trimmedQuery)) {
if (exact) {
// Exact public key match
where.push(`public_key = {publicKeyExact_${index}:String}`);
queryParams[`publicKeyExact_${index}`] = trimmedQuery.toUpperCase();
} else {
// Search by public key prefix
where.push(`public_key LIKE {publicKeyPattern_${index}:String}`);
queryParams[`publicKeyPattern_${index}`] = `${trimmedQuery.toUpperCase()}%`;
}
} else {
if (exact) {
// Exact node name match (case insensitive)
where.push(`lower(node_name) = {nameExact_${index}:String}`);
queryParams[`nameExact_${index}`] = trimmedQuery.toLowerCase();
} else {
// Search by node name (case insensitive, anywhere in the name)
where.push(`lower(node_name) LIKE {namePattern_${index}:String}`);
queryParams[`namePattern_${index}`] = `%${trimmedQuery.toLowerCase()}%`;
}
}
}
}
// Add lastSeen filter if provided
if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") {
where.push(`last_seen >= now() - INTERVAL {lastSeen_${index}:UInt32} SECOND`);
queryParams[`lastSeen_${index}`] = Number(lastSeen);
}
// Add region filtering if specified
const regionFilter = generateRegionWhereClause(region);
if (regionFilter.whereClause) {
where.push(regionFilter.whereClause);
}
// Add is_repeater filter if specified
if (is_repeater !== undefined) {
where.push(`is_repeater = {isRepeater_${index}:UInt8}`);
queryParams[`isRepeater_${index}`] = is_repeater ? 1 : 0;
}
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const queryPart = `
SELECT
public_key,
node_name,
latitude,
longitude,
has_location,
is_repeater,
is_chat_node,
is_room_server,
has_name,
first_heard,
last_seen,
broker,
topic,
${index} as query_index
FROM (
SELECT
public_key,
argMax(node_name, ingest_timestamp) as node_name,
argMax(latitude, ingest_timestamp) as latitude,
argMax(longitude, ingest_timestamp) as longitude,
argMax(has_location, ingest_timestamp) as has_location,
argMax(is_repeater, ingest_timestamp) as is_repeater,
argMax(is_chat_node, ingest_timestamp) as is_chat_node,
argMax(is_room_server, ingest_timestamp) as is_room_server,
argMax(has_name, ingest_timestamp) as has_name,
min(ingest_timestamp) as first_heard,
max(ingest_timestamp) as last_seen,
argMax(broker, ingest_timestamp) as broker,
argMax(topic, ingest_timestamp) as topic
FROM meshcore_adverts
GROUP BY public_key
)
${whereClause}
ORDER BY last_seen DESC
LIMIT {limit_${index}:UInt32}
`;
queryParts.push(queryPart);
queryParams[`limit_${index}`] = limit;
// Add query params to the global params object
Object.assign(allParams, queryParams);
});
// Add lastSeen filter if provided
if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") {
where.push('last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND');
params.lastSeen = Number(lastSeen);
}
// Add region filtering if specified
const regionFilter = generateRegionWhereClause(region);
if (regionFilter.whereClause) {
where.push(regionFilter.whereClause);
}
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const query = `
SELECT
public_key,
node_name,
latitude,
longitude,
has_location,
is_repeater,
is_chat_node,
is_room_server,
has_name,
first_heard,
last_seen,
broker,
topic
FROM meshcore_adverts_latest
${whereClause}
ORDER BY last_seen DESC
LIMIT {limit:UInt32}
`;
// Combine all queries with UNION ALL
const finalQuery = queryParts.join(' UNION ALL ');
const resultSet = await clickhouse.query({
query,
query_params: params,
query: finalQuery,
query_params: allParams,
format: 'JSONEachRow'
});
const rows = await resultSet.json();
return rows as Array<{
type SearchResult = {
public_key: string;
node_name: string;
latitude: number | null;
@@ -453,7 +520,30 @@ export async function searchMeshcoreNodes({
last_seen: string;
broker: string;
topic: string;
}>;
query_index?: number;
};
// If single query, return results without query_index
if (!Array.isArray(searchParams)) {
return (rows as SearchResult[]).map(row => {
const { query_index, ...result } = row;
return result;
});
}
// For batch queries, group results by query_index
const groupedResults = (rows as SearchResult[]).reduce((acc, row) => {
const index = row.query_index || 0;
if (!acc[index]) {
acc[index] = [];
}
const { query_index, ...result } = row;
acc[index].push(result);
return acc;
}, {} as Record<number, SearchResult[]>);
// Return results in the same order as input queries
return queries.map((_, index) => groupedResults[index] || []);
} catch (error) {
console.error('ClickHouse error in searchMeshcoreNodes:', error);
throw error;