mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-07-29 12:52:22 +02:00
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:
@@ -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 & 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) {
|
||||
|
||||
@@ -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 & 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 ${
|
||||
|
||||
@@ -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)" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user