mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-08-08 09:32:43 +02:00
Search batching, display nodes on graph, spinner for loading neighbors
This commit is contained in:
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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}®ion=${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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{nodeName}"</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 "{nodeName}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}®ion=${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}®ion=${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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user