Unify neighbor inference: global confidence setting, hover + node page

- Move the neighbor-confidence preference from per-map-layer settings to global
  Settings (ConfigContext); the map all-neighbors layer, map hover, and node
  page all read it. Default "Standard" (0.5).
- Map hover and the node detail page now derive neighbors from the unified graph
  (meshcore_all_neighbor_edges) via a new getMeshcoreNodeAllEdges + a mode=all
  option on the node neighbors route/hook, so they match "show all neighbors".
- Node page shows direct and inferred neighbors in one list; inferred cards carry
  an "Inferred · <method>" + confidence indicator instead of direction arrows.
- Color map hover lines by derivation method (purple/blue/teal), fade by confidence.
- Remove the "Manage Channel Keys" button from the Settings popover (still reachable
  via the + on the messages page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-06-19 21:21:06 -04:00
parent 9191bcaab0
commit 6bc58d6fdc
8 changed files with 206 additions and 90 deletions
@@ -9,7 +9,7 @@ import { formatPublicKey } from "@/lib/meshcore";
import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils";
import AdvertDetails from "@/components/AdvertDetails";
import ContactQRCode from "@/components/ContactQRCode";
import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext";
import { useConfig, LAST_SEEN_OPTIONS, neighborMinConfidenceOf } 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";
@@ -54,6 +54,34 @@ export default function MeshcoreNodePage() {
enabled: !!publicKey
});
// Inferred neighbors from the unified neighbor graph (path/anchor derived), filtered by the user's
// global confidence preference. Exclude 'direct' (already shown above) and any neighbor already in
// the direct list, so this section only adds genuinely inferred links.
const {
data: allEdges = [],
isLoading: inferredLoading
} = useNeighbors({
nodeId: publicKey,
lastSeen: config.lastSeen,
mode: 'all',
minConfidence: neighborMinConfidenceOf(config),
enabled: !!publicKey
});
const directKeys = new Set(neighbors.map(n => n.public_key));
const inferredNeighbors = allEdges.filter(n => n.method !== 'direct' && !directKeys.has(n.public_key));
const methodLabel = (m?: string) =>
m === 'anchor-gateway' || m === 'anchor-origin' ? 'Anchored'
: m === 'path-uniq-3b' ? 'Path (3-byte)'
: m === 'path-uniq-2b' ? 'Path (2-byte)'
: m === 'path-uniq-1b' ? 'Path (1-byte)'
: 'Inferred';
// One list: directly-heard neighbors first, then inferred ones, each flagged so the card can show
// either direction arrows (direct) or an inference indicator (inferred).
const combinedNeighbors = [
...neighbors.map(n => ({ ...n, inferred: false })),
...inferredNeighbors.map(n => ({ ...n, inferred: true })),
];
// Extract error information from TanStack Query error
const error = queryError?.error || null;
const errorCode = queryError?.code || null;
@@ -406,10 +434,10 @@ export default function MeshcoreNodePage() {
<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})
Neighbors ({neighborsLoading || inferredLoading ? "..." : combinedNeighbors.length})
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Nodes heard directly by this node
Heard directly, or inferred from routing paths &amp; adverts
{config.lastSeen !== null && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Last {(() => {
@@ -421,18 +449,18 @@ export default function MeshcoreNodePage() {
</p>
</div>
<div className="p-6">
{neighborsLoading ? (
{(neighborsLoading || inferredLoading) ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
Loading neighbors...
</div>
) : neighbors.length === 0 ? (
) : combinedNeighbors.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
No neighbors found
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{neighbors.map((neighbor) => (
{combinedNeighbors.map((neighbor) => (
<div key={neighbor.public_key} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
@@ -480,13 +508,25 @@ export default function MeshcoreNodePage() {
Location: {neighbor.latitude.toFixed(4)}, {neighbor.longitude.toFixed(4)}
</div>
) || null}
{neighbor.directions && neighbor.directions.length > 0 && (
{neighbor.inferred ? (
<div className="flex flex-wrap items-center gap-1 pt-0.5">
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200"
title="Inferred from routing paths / adverts — not heard directly"
>
Inferred · {methodLabel(neighbor.method)}
</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-neutral-700 dark:text-gray-300">
{Math.round((neighbor.confidence ?? 0) * 100)}% conf
</span>
</div>
) : (neighbor.directions && neighbor.directions.length > 0 && (
<div className="flex items-center gap-1">
<span>Direction:</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>
</div>
))}
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getMeshcoreNodeNeighbors } from "@/lib/clickhouse/actions";
import { getMeshcoreNodeNeighbors, getMeshcoreNodeAllEdges } from "@/lib/clickhouse/actions";
export async function GET(
req: Request,
@@ -9,7 +9,11 @@ export async function GET(
const { publicKey: rawPublicKey } = await params;
const { searchParams } = new URL(req.url);
const lastSeen = searchParams.get("lastSeen");
// mode=all -> unified neighbor graph (same edges as the map's "show all neighbors"), filtered by
// minConfidence. Default mode -> the legacy direct-adjacency list (with incoming/outgoing).
const mode = searchParams.get("mode");
const minConfidence = searchParams.get("minConfidence");
if (!rawPublicKey) {
return NextResponse.json({
error: "Public key is required",
@@ -28,8 +32,10 @@ export async function GET(
// Normalize public key to uppercase for database query
const publicKey = rawPublicKey.toUpperCase();
const neighbors = await getMeshcoreNodeNeighbors(publicKey, lastSeen);
const neighbors = mode === "all"
? await getMeshcoreNodeAllEdges(publicKey, minConfidence ? Number(minConfidence) : 0, lastSeen)
: await getMeshcoreNodeNeighbors(publicKey, lastSeen);
// Check if the parent node exists by trying to get basic node info
// This is a lightweight check to ensure the node exists before returning neighbors
if (!neighbors || neighbors.length === 0) {
+34 -7
View File
@@ -15,6 +15,7 @@ export type Config = {
lastSeen: number | null; // seconds, or null for forever
meshcoreKeys?: MeshcoreKey[]; // meshcore private keys
selectedRegion?: string; // selected region for chat messages
neighborMinConfidence?: number; // min confidence for derived/inferred neighbor edges (0..1)
};
@@ -22,8 +23,27 @@ const DEFAULT_CONFIG: Config = {
lastSeen: 604800, // 1 week by default
meshcoreKeys: [], // default empty
selectedRegion: undefined, // no region selected by default
neighborMinConfidence: 0.5, // "Standard": direct + extended-hash path edges; hides anchored/1-byte
};
// Neighbor-edge confidence tiers, mapped to the minimum `confidence` emitted by
// meshcore_all_neighbor_edges (direct=1.0, 3-byte≈0.8, 2-byte≈0.6, anchored≈0.45, 1-byte≈0.4).
// Anchored edges are geographically inferred (not observed hops) so they rank just above the noisy
// 1-byte tier and are hidden at the default "Standard" threshold.
export const NEIGHBOR_CONFIDENCE_OPTIONS = [
{ value: 1.0, label: "MQTT direct only" },
{ value: 0.7, label: "High (extended hash)" },
{ value: 0.5, label: "Standard" },
{ value: 0.45, label: "Include anchored (lower confidence)" },
{ value: 0, label: "All (include weak 1-byte)" },
];
// Resolve the effective confidence floor (handles older stored configs missing the field).
export function neighborMinConfidenceOf(config: Config | undefined | null): number {
const v = config?.neighborMinConfidence;
return typeof v === "number" ? v : 0.5;
}
export const LAST_SEEN_OPTIONS = [
{ value: 1800, label: "30m" },
{ value: 3600, label: "1h" },
@@ -68,7 +88,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
return (
<ConfigContext.Provider value={{ config, setConfig, openConfig, openKeyModal, configButtonRef }}>
{children}
{open && <ConfigPopover config={config} setConfig={setConfig} onClose={closeConfig} anchorRef={configButtonRef} onOpenKeyModal={openKeyModal} />}
{open && <ConfigPopover config={config} setConfig={setConfig} onClose={closeConfig} anchorRef={configButtonRef} />}
{keyModalOpen && (
<MeshcoreKeyModal
config={config}
@@ -90,7 +110,7 @@ export function useConfig() {
return useContext(ConfigContext);
}
function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }: { config: Config, setConfig: (c: Config) => void, onClose: () => void, anchorRef: React.RefObject<HTMLElement | null>, onOpenKeyModal: () => void }) {
function ConfigPopover({ config, setConfig, onClose, anchorRef }: { config: Config, setConfig: (c: Config) => void, onClose: () => void, anchorRef: React.RefObject<HTMLElement | null> }) {
const popoverRef = useRef<HTMLDivElement>(null);
// Click outside to close
@@ -152,12 +172,19 @@ function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }
</p>
</div>
<div className="mb-2">
<button
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 w-full"
onClick={onOpenKeyModal}
<div className="font-medium mb-2">Neighbor confidence</div>
<select
className="w-full p-2 border rounded"
value={config.neighborMinConfidence ?? 0.5}
onChange={e => setConfig({ ...config, neighborMinConfidence: parseFloat(e.target.value) })}
>
Manage Channel Keys
</button>
{NEIGHBOR_CONFIDENCE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Minimum confidence for inferred neighbor links (map &amp; node pages)
</p>
</div>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { useMapLayerSettings, TILE_LAYERS, NODE_TYPE_OPTIONS, NEIGHBOR_CONFIDENCE_OPTIONS, type MapLayerSettings } from '@/hooks/useMapLayerSettings';
import { useMapLayerSettings, TILE_LAYERS, NODE_TYPE_OPTIONS, type MapLayerSettings } from '@/hooks/useMapLayerSettings';
import { Square3Stack3DIcon } from '@heroicons/react/24/outline';
interface MapLayerSettingsProps {
@@ -189,39 +189,6 @@ export default function MapLayerSettingsComponent({ onSettingsChange }: MapLayer
</span>
</label>
{/* Neighbor confidence - indented sub-option (subsumes the old "only MQTT" toggle:
"MQTT direct only" is the top tier) */}
<div className="ml-6 mb-3">
<label className={`block text-sm ${
settings.showAllNeighbors
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500'
} mb-1`}>
Neighbor confidence
</label>
<select
value={settings.minConfidence}
onChange={(e) => updateSetting('minConfidence', parseFloat(e.target.value))}
disabled={!settings.showAllNeighbors}
className={`w-full p-2 border border-gray-300 dark:border-neutral-600 rounded text-sm ${
settings.showAllNeighbors
? 'bg-white dark:bg-neutral-800 text-gray-700 dark:text-gray-300'
: 'bg-gray-100 dark:bg-neutral-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
}`}
>
{NEIGHBOR_CONFIDENCE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className={`text-xs mt-1 ${
settings.showAllNeighbors
? 'text-gray-500 dark:text-gray-400'
: 'text-gray-400 dark:text-gray-500'
}`}>
Higher tiers show only the most trustworthy links (MQTT-direct, then anchored / extended-hash)
</p>
</div>
{/* Minimum packet count - indented sub-option */}
<div className="ml-6 mb-3">
<label className={`block text-sm ${
+19 -18
View File
@@ -7,7 +7,7 @@ import L from "leaflet";
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { useConfig } from "./ConfigContext";
import { useConfig, neighborMinConfidenceOf } from "./ConfigContext";
import RefreshButton from "@/components/RefreshButton";
import MapLayerSettingsComponent from "@/components/MapLayerSettings";
import { type MapLayerSettings } from "@/hooks/useMapLayerSettings";
@@ -373,11 +373,6 @@ function NeighborLines({
.map(neighbor => {
// Check if the neighbor is also visible on the map
const neighborOnMap = nodes.find(node => node.node_id === neighbor.public_key);
const hasIncoming = neighbor.directions?.includes('incoming') || false;
const hasOutgoing = neighbor.directions?.includes('outgoing') || false;
const isBidirectional = hasIncoming && hasOutgoing;
return {
neighbor,
positions: [
@@ -385,26 +380,29 @@ function NeighborLines({
[neighbor.latitude!, neighbor.longitude!] as [number, number]
],
isNeighborVisible: !!neighborOnMap,
hasIncoming,
hasOutgoing,
isBidirectional
};
});
// Color by derivation method, matching the "show all neighbors" layer.
const methodColor = (method?: string) =>
method === 'direct' ? '#8b5cf6' // purple: literal MQTT-direct
: method?.startsWith('anchor-') ? '#3b82f6' // blue: anchored
: '#14b8a6'; // teal: path-inferred
return (
<>
{lines.map(({ neighbor, positions, isNeighborVisible, isBidirectional }) => {
const lineColor = isNeighborVisible ? (isBidirectional ? '#10b981' : '#3b82f6') : '#94a3b8';
{lines.map(({ neighbor, positions, isNeighborVisible }) => {
const lineColor = isNeighborVisible ? methodColor(neighbor.method) : '#94a3b8';
const opacity = Math.max(0.3, Math.min(0.9, 0.3 + 0.6 * (neighbor.confidence ?? 0.6)));
return (
<Polyline
key={`${selectedNodeId}-${neighbor.public_key}`}
positions={positions}
pathOptions={{
color: lineColor,
weight: isBidirectional ? strokeWidth + 1 : strokeWidth,
opacity: 0.7,
weight: strokeWidth,
opacity,
dashArray: isNeighborVisible ? undefined : '5, 5'
}}
/>
@@ -551,7 +549,6 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
tileLayer: "openstreetmap",
showAllNeighbors: false,
useColors: true,
minConfidence: 0.5,
nodeTypes: ["meshcore"],
showMeshcoreCoverageOverlay: false,
minPacketCount: 1,
@@ -583,9 +580,13 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
}, [mapLayerSettings.showAllNeighbors]);
// Use TanStack Query for neighbors data
// Hover neighbors come from the same unified graph as "show all neighbors", filtered by the
// user's global confidence preference, so the two views are consistent.
const { data: neighbors = [], isLoading: neighborsLoading } = useNeighbors({
nodeId: selectedNodeId,
lastSeen: config?.lastSeen,
mode: 'all',
minConfidence: neighborMinConfidenceOf(config),
enabled: !!selectedNodeId
});
@@ -651,7 +652,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
}
if (includeNeighbors) {
params.push('includeNeighbors=true');
params.push(`minConfidence=${mapLayerSettings.minConfidence}`);
params.push(`minConfidence=${neighborMinConfidenceOf(config)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
@@ -697,7 +698,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
setAllNeighborsLoading(false);
}
});
}, [mapLayerSettings.nodeTypes, mapLayerSettings.minConfidence, config?.lastSeen, config?.selectedRegion]);
}, [mapLayerSettings.nodeTypes, config?.neighborMinConfidence, config?.lastSeen, config?.selectedRegion]);
function isBoundsInside(inner: [[number, number], [number, number]], outer: [[number, number], [number, number]]) {
// inner: [[minLat, minLng], [maxLat, maxLng]]
@@ -903,7 +904,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
{/* Traffic Legend */}
{showAllNeighbors && mapLayerSettings.useColors && allNeighborConnections.length > 0 && (() => {
// At the top confidence notch only literal MQTT-direct edges are shown.
const directOnly = mapLayerSettings.minConfidence >= 1;
const directOnly = neighborMinConfidenceOf(config) >= 1;
const hasAnchor = allNeighborConnections.some(conn => conn.method?.startsWith('anchor-'));
// Calculate logarithmic thresholds for legend display
const pathConnections = allNeighborConnections.filter(conn => conn.connection_type === 'path');
@@ -10,9 +10,6 @@ export interface MapLayerSettings {
tileLayer: string;
showAllNeighbors: boolean;
useColors: boolean;
// Minimum edge confidence to display. Subsumes the old "only MQTT neighbors" toggle: at 1.0 only
// literal MQTT-direct edges show; lower values progressively include anchored and path-inferred edges.
minConfidence: number;
nodeTypes: NodeType[];
showMeshcoreCoverageOverlay: boolean;
minPacketCount: number;
@@ -26,7 +23,6 @@ const DEFAULT_MAP_LAYER_SETTINGS: MapLayerSettings = {
tileLayer: "openstreetmap",
showAllNeighbors: false,
useColors: true,
minConfidence: 0.5,
nodeTypes: ["meshcore"],
showMeshcoreCoverageOverlay: false,
minPacketCount: 1,
@@ -47,14 +43,3 @@ export const NODE_TYPE_OPTIONS = [
{ key: "meshcore", label: "Meshcore" },
];
// Neighbor confidence tiers, mapped to the minimum edge confidence emitted by
// meshcore_all_neighbor_edges (direct=1.0, 3-byte≈0.8, 2-byte≈0.6, anchored≈0.45, 1-byte≈0.4).
// Anchored edges are geographically inferred (not observed hops) so they rank just above the noisy
// 1-byte tier and are hidden at the default "Standard" threshold.
export const NEIGHBOR_CONFIDENCE_OPTIONS = [
{ value: 1.0, label: "MQTT direct only" },
{ value: 0.7, label: "High (extended hash)" },
{ value: 0.5, label: "Standard" },
{ value: 0.45, label: "Include anchored (lower confidence)" },
{ value: 0, label: "All (include weak 1-byte)" },
];
+19 -4
View File
@@ -11,25 +11,40 @@ export interface Neighbor {
is_chat_node: number;
is_room_server: number;
has_name: number;
directions: string[];
directions?: string[]; // only present in 'direct' mode
// present in 'all' mode (unified neighbor graph)
method?: string;
confidence?: number;
connection_type?: string;
packet_count?: number;
}
interface UseNeighborsParams {
nodeId: string | null;
lastSeen?: number | null;
// 'direct' (default) = legacy direct adjacency with incoming/outgoing; 'all' = unified neighbor
// graph (same edges as the map's "show all neighbors") filtered by minConfidence.
mode?: 'direct' | 'all';
minConfidence?: number | null;
enabled?: boolean;
}
export function useNeighbors({ nodeId, lastSeen, enabled = true }: UseNeighborsParams) {
export function useNeighbors({ nodeId, lastSeen, mode = 'direct', minConfidence, enabled = true }: UseNeighborsParams) {
return useQuery({
queryKey: ['neighbors', nodeId, lastSeen],
queryKey: ['neighbors', nodeId, lastSeen, mode, minConfidence],
queryFn: async (): Promise<Neighbor[]> => {
if (!nodeId) return [];
const params = new URLSearchParams();
if (lastSeen !== null && lastSeen !== undefined) {
params.append('lastSeen', lastSeen.toString());
}
if (mode === 'all') {
params.append('mode', 'all');
if (minConfidence !== null && minConfidence !== undefined) {
params.append('minConfidence', minConfidence.toString());
}
}
const url = `/api/meshcore/node/${nodeId}/neighbors${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(buildApiUrl(url));
@@ -474,6 +474,81 @@ export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: stri
}
}
// A node's neighbors derived from the unified neighbor graph (meshcore_all_neighbor_edges) — the
// same edges the map's "show all neighbors" layer draws — filtered to a minimum confidence. Returns
// the other endpoint of each edge with its derivation method/confidence; dedups a neighbor seen in
// multiple regions to its highest-confidence edge.
export async function getMeshcoreNodeAllEdges(publicKey: string, minConfidence: number = 0, lastSeen: string | null = null) {
try {
const params: Record<string, any> = { publicKey, minConfidence: Number(minConfidence) };
const whereConditions = [
"(source_node = {publicKey:String} OR target_node = {publicKey:String})",
"confidence >= {minConfidence:Float32}",
visibleNodeSqlClause("source_name"),
visibleNodeSqlClause("target_name"),
];
if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") {
whereConditions.push("source_last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND AND target_last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND");
params.lastSeen = Number(lastSeen);
}
const query = `
SELECT
e.public_key AS public_key,
any(e.node_name) AS node_name,
any(e.latitude) AS latitude,
any(e.longitude) AS longitude,
any(e.has_location) AS has_location,
argMax(e.method, e.confidence) AS method,
max(e.confidence) AS confidence,
argMax(e.connection_type, e.confidence) AS connection_type,
max(e.packet_count) AS packet_count,
any(a.is_repeater) AS is_repeater,
any(a.is_chat_node) AS is_chat_node,
any(a.is_room_server) AS is_room_server,
any(a.has_name) AS has_name
FROM (
SELECT
if(source_node = {publicKey:String}, target_node, source_node) AS public_key,
if(source_node = {publicKey:String}, target_name, source_name) AS node_name,
if(source_node = {publicKey:String}, target_latitude, source_latitude) AS latitude,
if(source_node = {publicKey:String}, target_longitude, source_longitude) AS longitude,
if(source_node = {publicKey:String}, target_has_location, source_has_location) AS has_location,
method, confidence, connection_type, packet_count
FROM meshcore_all_neighbor_edges
WHERE ${whereConditions.join(" AND ")}
) AS e
LEFT JOIN (
SELECT public_key, is_repeater, is_chat_node, is_room_server, has_name FROM meshcore_adverts_latest
) AS a ON a.public_key = e.public_key
WHERE e.public_key != {publicKey:String}
GROUP BY e.public_key
ORDER BY confidence DESC, public_key
`;
const result = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' });
const rows = await result.json();
return rows as Array<{
public_key: string;
node_name: string;
latitude: number | null;
longitude: number | null;
has_location: number;
method: string;
confidence: number;
connection_type: string;
packet_count: number;
is_repeater: number;
is_chat_node: number;
is_room_server: number;
has_name: number;
}>;
} catch (error) {
console.error('ClickHouse error in getMeshcoreNodeAllEdges:', error);
throw error;
}
}
interface SearchQuery {
query?: string;
region?: string;