Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/components/Dashboard/DashboardMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getTilesetById } from '../../config/tilesets';
import type { CustomTileset, TilesetId } from '../../config/tilesets';
import DashboardWaypoints from './DashboardWaypoints';
import DashboardNodePopup, { type NodeSourceRef } from './DashboardNodePopup';
import DashboardNeighborPopup from './DashboardNeighborPopup';
import GeoJsonOverlay from '../GeoJsonOverlay';
import { TilesetSelector } from '../TilesetSelector';
import MapLegend from '../MapLegend';
Expand Down Expand Up @@ -595,12 +596,22 @@ export default function DashboardMap({
? { color: colorByTransport, weight: 2, opacity: 0.6 }
: { color: colorByTransport, weight: 1, opacity: 0.6, dashArray: '5, 5' };

// Stable key by canonical node-pair (not array index) so the deduped
// line keeps its identity across polls.
const pairKey = link.nodeNum != null && link.neighborNodeNum != null
? `${Math.min(Number(link.nodeNum), Number(link.neighborNodeNum))}-${Math.max(Number(link.nodeNum), Number(link.neighborNodeNum))}`
: `idx-${idx}`;

return (
<Polyline
key={`neighbor-link-${idx}`}
key={`neighbor-link-${pairKey}`}
positions={positions}
pathOptions={pathOptions}
/>
>
<Popup>
<DashboardNeighborPopup link={link} />
</Popup>
</Polyline>
);
})}
</MapContainer>
Expand Down
86 changes: 86 additions & 0 deletions src/components/Dashboard/DashboardNeighborPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardNeighborPopup from './DashboardNeighborPopup';

const recent = Date.now() - 2 * 60 * 1000; // 2 minutes ago (ms)

describe('DashboardNeighborPopup', () => {
it('exposes BOTH directions of a bidirectional link (issue #3777)', () => {
render(
<DashboardNeighborPopup
link={{
nodeNum: 10,
neighborNodeNum: 20,
nodeName: 'Alpha',
neighborName: 'Bravo',
bidirectional: true,
transportClass: 'rf',
snr: 5.25,
timestamp: recent,
reverseSnr: -2.5,
reverseTimestamp: recent,
}}
/>,
);

// Header shows both endpoints with a bidirectional indicator.
expect(screen.getByText(/Alpha\s*↔\s*Bravo/)).toBeInTheDocument();
expect(screen.getByText(/Bidirectional/)).toBeInTheDocument();

// Forward direction: Alpha → Bravo with its own SNR.
expect(screen.getByText(/Alpha → Bravo: SNR 5\.25 dB/)).toBeInTheDocument();
// Reverse direction: Bravo → Alpha with the reverse SNR (would be lost
// without the reverse-data backfill).
expect(screen.getByText(/Bravo → Alpha: SNR -2\.50 dB/)).toBeInTheDocument();
});

it('shows a single direction and a one-way indicator when not bidirectional', () => {
render(
<DashboardNeighborPopup
link={{
nodeNum: 1,
neighborNodeNum: 2,
nodeName: 'Solo',
neighborName: 'Mate',
bidirectional: false,
transportClass: 'mqtt',
snr: 8,
timestamp: recent,
reverseSnr: null,
}}
/>,
);

// Exact match targets the header title (the SNR row's text is longer).
expect(screen.getByText('Solo → Mate')).toBeInTheDocument();
expect(screen.getByText(/One-way · MQTT/)).toBeInTheDocument();
expect(screen.getByText(/Solo → Mate: SNR 8\.00 dB/)).toBeInTheDocument();
// No reverse row when there's no reverse data.
expect(screen.queryByText(/Mate → Solo/)).not.toBeInTheDocument();
});

it('renders an em dash when a direction has no SNR', () => {
render(
<DashboardNeighborPopup
link={{
nodeNum: 3,
neighborNodeNum: 4,
nodeName: 'X',
neighborName: 'Y',
bidirectional: true,
transportClass: 'rf',
snr: null,
timestamp: recent,
reverseSnr: 1.5,
reverseTimestamp: recent,
}}
/>,
);

expect(screen.getByText(/X → Y: SNR —/)).toBeInTheDocument();
expect(screen.getByText(/Y → X: SNR 1\.50 dB/)).toBeInTheDocument();
});
});
89 changes: 89 additions & 0 deletions src/components/Dashboard/DashboardNeighborPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* DashboardNeighborPopup — popup shown when a neighbor-info link on the
* Dashboard map is clicked.
*
* Neighbor links are deduplicated to a single polyline per unordered node-pair
* (issue #3777), so a bidirectional A↔B link is drawn once. This popup exposes
* BOTH directions of the link so the reverse direction's signal data isn't lost
* to the dedup: the kept record's own `snr`/`lastRxTime` describe the forward
* direction (node → neighbor), and `reverseSnr`/`reverseLastRxTime` (attached by
* buildSourceNeighborInfo) describe the reverse direction (neighbor → node).
*
* Reuses the `.node-popup-*` classes (styles/nodes.css) for visual parity with
* the node marker popup.
*/

import { formatRelativeTime } from '../../utils/datetime';

interface DashboardNeighborPopupProps {
link: any;
}

function formatSnr(snr: unknown): string {
return typeof snr === 'number' && Number.isFinite(snr) ? `${snr.toFixed(2)} dB` : '—';
}

/**
* Report time for a direction, formatted relative to now. Uses the NeighborInfo
* report `timestamp` (milliseconds — the same field the server freshness window
* divides by 1000). `lastRxTime` is intentionally not used here: its unit is
* ambiguous across firmware and would risk a wrong "heard" age.
*/
function formatHeard(timestamp: unknown): string | null {
const ms = typeof timestamp === 'number' && timestamp > 0 ? timestamp : null;
return ms == null ? null : formatRelativeTime(ms);
}

const TRANSPORT_LABEL: Record<string, string> = {
rf: 'RF (LoRa)',
udp: 'UDP',
mqtt: 'MQTT',
};

export default function DashboardNeighborPopup({ link }: DashboardNeighborPopupProps) {
const nodeName: string = link?.nodeName ?? link?.nodeId ?? 'Node';
const neighborName: string = link?.neighborName ?? link?.neighborNodeId ?? 'Neighbor';
const bidirectional = !!link?.bidirectional;
const transportClass: string = link?.transportClass ?? 'rf';

const forwardHeard = formatHeard(link?.timestamp);
const reverseHeard = formatHeard(link?.reverseTimestamp);
// The reverse direction's data exists when the link is bidirectional (the dedup
// dropped that row but stashed its signal on the kept record).
const hasReverse = bidirectional || link?.reverseSnr != null;

return (
<div className="node-popup neighbor-popup">
<div className="node-popup-header">
<div className="node-popup-title">
{nodeName} {bidirectional ? '↔' : '→'} {neighborName}
</div>
<span className="node-popup-subtitle">
{bidirectional ? 'Bidirectional' : 'One-way'} · {TRANSPORT_LABEL[transportClass] ?? transportClass}
</span>
</div>

<div className="node-popup-content">
<div className="node-popup-grid">
<div className="node-popup-item node-popup-item-full">
<span className="node-popup-icon">📶</span>
<span className="node-popup-value">
{nodeName} → {neighborName}: SNR {formatSnr(link?.snr)}
{forwardHeard ? ` · heard ${forwardHeard}` : ''}
</span>
</div>

{hasReverse && (
<div className="node-popup-item node-popup-item-full">
<span className="node-popup-icon">📶</span>
<span className="node-popup-value">
{neighborName} → {nodeName}: SNR {formatSnr(link?.reverseSnr)}
{reverseHeard ? ` · heard ${reverseHeard}` : ''}
</span>
</div>
)}
</div>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions src/db/repositories/nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,30 @@ function runNodesTests(getBackend: () => TestBackend) {
expect(map.size).toBe(0);
});

it('getNodesByNums - scopes to sourceId when provided (#3780)', async () => {
const backend = getBackend();
if (!backend.available) {
console.log(`⚠ Skipped: ${backend.skipReason}`);
return;
}

// Insert the same nodeNum under two sources with different transportMechanism values.
await repo.upsertNode(makeNode(500, { sourceId: 'rf-source', transportMechanism: 1 }));
await repo.upsertNode(makeNode(500, { sourceId: 'mqtt-source', transportMechanism: 5 }));

const rfMap = await repo.getNodesByNums([500], 'rf-source');
expect(rfMap.size).toBe(1);
expect(rfMap.get(500)!.transportMechanism).toBe(1);

const mqttMap = await repo.getNodesByNums([500], 'mqtt-source');
expect(mqttMap.size).toBe(1);
expect(mqttMap.get(500)!.transportMechanism).toBe(5);

// Without sourceId, any matching row is returned (both sources present).
const unscoped = await repo.getNodesByNums([500]);
expect(unscoped.size).toBe(1); // at most one row per nodeNum in the result map
});

// --- getLowBatteryMonitoredNodes ---

it('getLowBatteryMonitoredNodes - returns only monitored nodes below threshold, excluding 101 and nulls', async () => {
Expand Down
12 changes: 10 additions & 2 deletions src/db/repositories/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export class NodesRepository extends BaseRepository {
/**
* Get multiple nodes by nodeNum in a single query
*/
async getNodesByNums(nodeNums: number[]): Promise<Map<number, DbNode>> {
async getNodesByNums(nodeNums: number[], sourceId?: string): Promise<Map<number, DbNode>> {
if (nodeNums.length === 0) return new Map();
// Filter out-of-range values up front (issue #3186) — one bad entry would
// otherwise fail the whole batch query against a PG `bigint` column.
Expand All @@ -164,10 +164,13 @@ export class NodesRepository extends BaseRepository {
if (validNums.length === 0) return new Map();

const { nodes } = this.tables;
const whereClause = sourceId
? and(inArray(nodes.nodeNum, validNums), eq(nodes.sourceId, sourceId))
: inArray(nodes.nodeNum, validNums);
const result = await this.db
.select()
.from(nodes)
.where(inArray(nodes.nodeNum, validNums));
.where(whereClause);

const map = new Map<number, DbNode>();
for (const row of result) {
Expand Down Expand Up @@ -370,6 +373,7 @@ export class NodesRepository extends BaseRepository {
role: nodeData.role ?? existingNode.role,
hopsAway: nodeData.hopsAway ?? existingNode.hopsAway,
viaMqtt: nodeData.viaMqtt ?? existingNode.viaMqtt,
transportMechanism: nodeData.transportMechanism ?? existingNode.transportMechanism,
isStoreForwardServer: nodeData.isStoreForwardServer ?? existingNode.isStoreForwardServer,
macaddr: nameOrExisting(nodeData.macaddr, existingNode.macaddr),
latitude: nodeData.latitude ?? existingNode.latitude,
Expand Down Expand Up @@ -425,6 +429,7 @@ export class NodesRepository extends BaseRepository {
role: nodeData.role ?? null,
hopsAway: nodeData.hopsAway ?? null,
viaMqtt: nodeData.viaMqtt ?? null,
transportMechanism: nodeData.transportMechanism ?? null,
isStoreForwardServer: nodeData.isStoreForwardServer ?? null,
macaddr: nodeData.macaddr ?? null,
latitude: nodeData.latitude ?? null,
Expand Down Expand Up @@ -484,6 +489,7 @@ export class NodesRepository extends BaseRepository {
role: nodeData.role ?? null,
hopsAway: nodeData.hopsAway ?? null,
viaMqtt: nodeData.viaMqtt ?? null,
transportMechanism: nodeData.transportMechanism ?? null,
isStoreForwardServer: nodeData.isStoreForwardServer ?? null,
macaddr: blankToNull(nodeData.macaddr),
latitude: nodeData.latitude ?? null,
Expand Down Expand Up @@ -1757,6 +1763,7 @@ export class NodesRepository extends BaseRepository {
setIfProvided('role', nodeData.role);
setIfProvided('hopsAway', nodeData.hopsAway);
if (nodeData.viaMqtt !== undefined) updateSet.viaMqtt = nodeData.viaMqtt;
if (nodeData.transportMechanism !== undefined) updateSet.transportMechanism = nodeData.transportMechanism;
setIfNonBlank('macaddr', nodeData.macaddr);
setIfProvided('latitude', nodeData.latitude);
setIfProvided('longitude', nodeData.longitude);
Expand Down Expand Up @@ -1808,6 +1815,7 @@ export class NodesRepository extends BaseRepository {
role: nodeData.role || null,
hopsAway: nodeData.hopsAway !== undefined ? nodeData.hopsAway : null,
viaMqtt: nodeData.viaMqtt !== undefined ? !!nodeData.viaMqtt : null,
transportMechanism: nodeData.transportMechanism !== undefined ? nodeData.transportMechanism : null,
macaddr: nodeData.macaddr || null,
latitude: nodeData.latitude || null,
longitude: nodeData.longitude || null,
Expand Down
53 changes: 53 additions & 0 deletions src/hooks/useDashboardData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,59 @@ describe('mergeUnifiedSourceData', () => {
expect([...classes].sort()).toEqual(['mqtt', 'rf']);
});

it('deduplicates neighbor links across sources (#3777)', () => {
// When two sources both report A-B as neighbors, only one line should render.
const linkAB = { nodeNum: 100, neighborNodeNum: 200, transportClass: 'rf', timestamp: 1000 };
const linkABdupe = { nodeNum: 100, neighborNodeNum: 200, transportClass: 'mqtt', timestamp: 2000 };
const merged = mergeUnifiedSourceData([
{
sourceId: 's1', sourceName: 'LoRa', protocol: 'Meshtastic',
nodes: [], traceroutes: [], channels: [],
neighborInfo: [linkAB],
},
{
sourceId: 's2', sourceName: 'MQTT', protocol: 'Meshtastic',
nodes: [], traceroutes: [], channels: [],
neighborInfo: [linkABdupe],
},
]);
// Only the first-seen link per canonical pair survives.
expect(merged.neighborInfo).toHaveLength(1);
expect((merged.neighborInfo[0] as any).nodeNum).toBe(100);
});

it('deduplicates reversed neighbor pairs across sources (#3777)', () => {
// Source 1 reports A→B; source 2 reports B→A. Canonical pair is the same.
const linkAB = { nodeNum: 100, neighborNodeNum: 200, transportClass: 'rf', timestamp: 1000 };
const linkBA = { nodeNum: 200, neighborNodeNum: 100, transportClass: 'rf', timestamp: 1000 };
const merged = mergeUnifiedSourceData([
{
sourceId: 's1', sourceName: 'S1', protocol: 'Meshtastic',
nodes: [], traceroutes: [], channels: [],
neighborInfo: [linkAB],
},
{
sourceId: 's2', sourceName: 'S2', protocol: 'Meshtastic',
nodes: [], traceroutes: [], channels: [],
neighborInfo: [linkBA],
},
]);
expect(merged.neighborInfo).toHaveLength(1);
});

it('keeps distinct neighbor links with different pairs', () => {
const linkAB = { nodeNum: 100, neighborNodeNum: 200, transportClass: 'rf', timestamp: 1000 };
const linkAC = { nodeNum: 100, neighborNodeNum: 300, transportClass: 'rf', timestamp: 1000 };
const merged = mergeUnifiedSourceData([
{
sourceId: 's1', sourceName: 'S1', protocol: 'Meshtastic',
nodes: [], traceroutes: [], channels: [],
neighborInfo: [linkAB, linkAC],
},
]);
expect(merged.neighborInfo).toHaveLength(2);
});

it('keeps distinct MeshCore nodes (different publicKeys) separate', () => {
const merged = mergeUnifiedSourceData([
{
Expand Down
20 changes: 19 additions & 1 deletion src/hooks/useDashboardData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ export function mergeUnifiedSourceData(
const recordsByKey = new Map<string, Array<{ node: any; source: NodeSourceRef | null }>>();
const traceroutes: unknown[] = [];
const neighborInfo: unknown[] = [];
// Tracks canonical (min,max) nodeNum pairs already added to neighborInfo so the
// same physical link reported by multiple sources renders as a single line.
const seenNeighborPairs = new Set<string>();
let channels: unknown[] = [];

for (const ps of perSource) {
Expand Down Expand Up @@ -369,7 +372,22 @@ export function mergeUnifiedSourceData(
else recordsByKey.set(key, [entry]);
}
traceroutes.push(...ps.traceroutes);
neighborInfo.push(...ps.neighborInfo);
for (const link of ps.neighborInfo as any[]) {
if (link == null) continue;
// Deduplicate across sources: the same physical pair (A,B) reported by
// multiple sources should render as a single line. Server-side
// buildSourceNeighborInfo already collapses bidirectional A↔B pairs within
// one source; here we also collapse the same pair appearing in a second
// source's neighborInfo bundle.
const numA = Number(link.nodeNum ?? 0);
const numB = Number(link.neighborNodeNum ?? 0);
if (numA !== 0 || numB !== 0) {
const pairKey = `${Math.min(numA, numB)}-${Math.max(numA, numB)}`;
if (seenNeighborPairs.has(pairKey)) continue;
seenNeighborPairs.add(pairKey);
}
neighborInfo.push(link);
}
if (channels.length === 0 && ps.channels.length > 0) {
channels = ps.channels;
}
Expand Down
Loading
Loading