diff --git a/src/app/(app)/meshcore/node/[publicKey]/page.tsx b/src/app/(app)/meshcore/node/[publicKey]/page.tsx index 2d52d01..8fb7f79 100644 --- a/src/app/(app)/meshcore/node/[publicKey]/page.tsx +++ b/src/app/(app)/meshcore/node/[publicKey]/page.tsx @@ -12,6 +12,7 @@ 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"; +import { RegionProvider } from "@/contexts/RegionContext"; // Interfaces are now imported from useNodeData hook @@ -193,10 +194,11 @@ export default function MeshcoreNodePage() { ); } - const { node, recentAdverts, locationHistory, mqtt } = nodeData; + const { node, recentAdverts, locationHistory, mqtt, region } = nodeData; return ( -
+ +
{/* Header */}
@@ -214,6 +216,11 @@ export default function MeshcoreNodePage() {

{formatPublicKey(node.public_key)}

+ {region && ( +

+ Region: {region} +

+ )}
{node.is_repeater && ( @@ -491,5 +498,6 @@ export default function MeshcoreNodePage() {
+ ); } diff --git a/src/components/PathVisualization.tsx b/src/components/PathVisualization.tsx index bb5c809..b8147a1 100644 --- a/src/components/PathVisualization.tsx +++ b/src/components/PathVisualization.tsx @@ -8,7 +8,7 @@ import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24 import PathDisplay from "./PathDisplay"; import { useMeshcoreSearches } from "@/hooks/useMeshcoreSearch"; import type { MeshcoreSearchResult } from "@/hooks/useMeshcoreSearch"; -import { useConfig } from "./ConfigContext"; +import { useConfigWithRegion } from "@/hooks/useConfigWithRegion"; export interface PathData { origin: string; @@ -47,7 +47,7 @@ export default function PathVisualization({ const [showGraph, setShowGraph] = useState(false); const [graphFullscreen, setGraphFullscreen] = useState(false); - const { config } = useConfig(); + const { config } = useConfigWithRegion(); const pathsCount = paths.length; // Process data for tree visualization diff --git a/src/contexts/RegionContext.tsx b/src/contexts/RegionContext.tsx new file mode 100644 index 0000000..950fc44 --- /dev/null +++ b/src/contexts/RegionContext.tsx @@ -0,0 +1,26 @@ +"use client"; +import React, { createContext, useContext, ReactNode } from "react"; + +interface RegionContextType { + region: string | null; +} + +const RegionContext = createContext(null); + +interface RegionProviderProps { + children: ReactNode; + region: string | null; +} + +export function RegionProvider({ children, region }: RegionProviderProps) { + return ( + + {children} + + ); +} + +export function useRegionContext() { + const context = useContext(RegionContext); + return context; +} diff --git a/src/hooks/useConfigWithRegion.ts b/src/hooks/useConfigWithRegion.ts new file mode 100644 index 0000000..3160c53 --- /dev/null +++ b/src/hooks/useConfigWithRegion.ts @@ -0,0 +1,25 @@ +import { useConfig } from "@/components/ConfigContext"; +import { useRegionContext } from "@/contexts/RegionContext"; + +/** + * Custom hook that combines ConfigContext with RegionContext + * When RegionContext is available, it overrides the selectedRegion from ConfigContext + */ +export function useConfigWithRegion() { + const config = useConfig(); + const regionContext = useRegionContext(); + + // If region context is available, override the selectedRegion + if (regionContext) { + return { + ...config, + config: { + ...config.config, + selectedRegion: regionContext.region + } + }; + } + + // Otherwise, return the normal config + return config; +} diff --git a/src/hooks/useNodeData.ts b/src/hooks/useNodeData.ts index 52210bc..226e8ff 100644 --- a/src/hooks/useNodeData.ts +++ b/src/hooks/useNodeData.ts @@ -11,6 +11,8 @@ export interface NodeInfo { is_chat_node: number; is_room_server: number; has_name: number; + broker: string | null; + topic: string | null; first_seen: string; last_seen: string; } @@ -53,6 +55,7 @@ export interface NodeData { recentAdverts: Advert[]; locationHistory: LocationHistory[]; mqtt: MqttInfo; + region: string | null; } export interface NodeError { diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index a88fcee..a4793d4 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -1,6 +1,7 @@ "use server"; import { clickhouse } from "./clickhouse"; import { generateRegionWhereClauseFromArray, generateRegionWhereClause } from "@/lib/regionFilters"; +import { getRegionConfig } from "@/lib/regions"; export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], lastSeen?: string | null } = {}) { try { @@ -95,6 +96,48 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel } } +/** + * Determines the region based on broker and topic information + * @param broker Broker string + * @param topic Topic string + * @returns The detected region name or null if no region matches + */ +function detectRegionFromBrokerTopic(broker: string | null, topic: string | null): string | null { + if (!broker || !topic) return null; + + // Check each region configuration + const regions = ['seattle', 'portland', 'boston']; + for (const regionName of regions) { + const regionConfig = getRegionConfig(regionName); + if (!regionConfig) continue; + + // Check if this topic/broker combination matches the region + if (broker === regionConfig.broker && regionConfig.topics.includes(topic)) { + return regionName; + } + } + + return null; +} + +/** + * Combined region detection that tries MQTT topics first, then advert data + * @param mqttTopics Array of MQTT topic information + * @param advertBroker Broker from advert data + * @param advertTopic Topic from advert data + * @returns The detected region name or null if no region matches + */ +function detectRegion(mqttTopics: Array<{ topic: string; broker: string }>, advertBroker: string | null, advertTopic: string | null): string | null { + // First try MQTT topics (more reliable for uplinked nodes) + for (const mqttTopic of mqttTopics) { + const region = detectRegionFromBrokerTopic(mqttTopic.broker, mqttTopic.topic); + if (region) return region; + } + + // Fallback to advert data (works for non-uplinked nodes) + return detectRegionFromBrokerTopic(advertBroker, advertTopic); +} + export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) { try { // Get basic node info from the latest advert and first seen time @@ -109,6 +152,8 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) 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, + argMax(broker, ingest_timestamp) as broker, + argMax(topic, ingest_timestamp) as topic, max(ingest_timestamp) as last_seen, min(ingest_timestamp) as first_seen FROM meshcore_adverts @@ -122,7 +167,21 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) query_params: { publicKey }, format: 'JSONEachRow' }); - const nodeInfo = await nodeInfoResult.json(); + const nodeInfo = await nodeInfoResult.json() as Array<{ + 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; + broker: string | null; + topic: string | null; + last_seen: string; + first_seen: string; + }>; if (!nodeInfo || nodeInfo.length === 0) { return null; @@ -232,6 +291,9 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) const hasPackets = mqttTopics.length > 0; const isUplinked = mqttTopics.some(topic => topic.is_recent); + // Detect region from MQTT topics and advert data + const detectedRegion = detectRegion(mqttTopics, nodeInfo[0].broker, nodeInfo[0].topic); + return { node: nodeInfo[0], recentAdverts: adverts, @@ -240,7 +302,8 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) is_uplinked: isUplinked, has_packets: hasPackets, topics: mqttTopics - } + }, + region: detectedRegion }; } catch (error) { console.error('ClickHouse error in getMeshcoreNodeInfo:', error);