mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Network map
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Map,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -33,6 +34,12 @@ const navigationItems: NavItem[] = [
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
to: "/map",
|
||||
label: "Network Map",
|
||||
icon: Map,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
to: "/packets",
|
||||
label: "Stream",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { Separator } from "../Separator";
|
||||
import { MessageBubble } from "../messages";
|
||||
import { Section } from "../ui/Section";
|
||||
import { ArrowLeft, MessageSquare, Users, Wifi } from "lucide-react";
|
||||
|
||||
interface ChannelDetailProps {
|
||||
|
||||
407
web/src/components/dashboard/NetworkMap.tsx
Normal file
407
web/src/components/dashboard/NetworkMap.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
interface NetworkMapProps {
|
||||
/** Height of the map in CSS units */
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NetworkMap displays all nodes with position data on a Google Map
|
||||
*/
|
||||
export const NetworkMap: React.FC<NetworkMapProps> = ({ height = "600px" }) => {
|
||||
const navigate = useNavigate();
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||
const markersRef = useRef<Record<string, google.maps.Marker>>({});
|
||||
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
|
||||
const boundsRef = useRef<google.maps.LatLngBounds>(new google.maps.LatLngBounds());
|
||||
const [nodesWithPosition, setNodesWithPosition] = useState<any[]>([]);
|
||||
const animatingNodesRef = useRef<Record<string, number>>({});
|
||||
|
||||
// Get nodes data from the store
|
||||
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
|
||||
|
||||
// Get the latest packet to detect when nodes receive packets
|
||||
const latestPacket = useAppSelector((state) =>
|
||||
state.packets.packets.length > 0 ? state.packets.packets[0] : null
|
||||
);
|
||||
|
||||
// Effect to build the list of nodes with position data
|
||||
useEffect(() => {
|
||||
const nodeArray = getNodesWithPosition(nodes, gateways);
|
||||
setNodesWithPosition(nodeArray);
|
||||
}, [nodes, gateways]);
|
||||
|
||||
// Handle map initialization and marker creation
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !window.google || !window.google.maps) return;
|
||||
|
||||
// Initialize map if not already done
|
||||
if (!mapInstanceRef.current) {
|
||||
initializeMap(mapRef.current);
|
||||
}
|
||||
|
||||
// Create info window if not already done
|
||||
if (!infoWindowRef.current) {
|
||||
infoWindowRef.current = new google.maps.InfoWindow();
|
||||
}
|
||||
|
||||
// Update markers and fit the map
|
||||
updateNodeMarkers(nodesWithPosition, navigate);
|
||||
|
||||
}, [nodesWithPosition, navigate]);
|
||||
|
||||
// Effect to detect when a node receives a packet and trigger animation
|
||||
useEffect(() => {
|
||||
if (latestPacket && latestPacket.data.from !== undefined) {
|
||||
const nodeId = latestPacket.data.from;
|
||||
const key = `node-${nodeId}`;
|
||||
|
||||
// If we have this node on the map, animate it
|
||||
if (markersRef.current[key]) {
|
||||
animateNodeMarker(nodeId);
|
||||
}
|
||||
}
|
||||
}, [latestPacket]);
|
||||
|
||||
// Function to animate a node marker when it receives a packet
|
||||
function animateNodeMarker(nodeId: number) {
|
||||
const key = `node-${nodeId}`;
|
||||
const marker = markersRef.current[key];
|
||||
const node = nodesWithPosition.find(n => n.id === nodeId);
|
||||
|
||||
if (!marker || !node) return;
|
||||
|
||||
// Clear any existing animation for this node
|
||||
if (animatingNodesRef.current[key]) {
|
||||
clearTimeout(animatingNodesRef.current[key]);
|
||||
}
|
||||
|
||||
// Set the animated style
|
||||
marker.setIcon(getMarkerIcon(node, true));
|
||||
|
||||
// Reset after a delay
|
||||
animatingNodesRef.current[key] = window.setTimeout(() => {
|
||||
marker.setIcon(getMarkerIcon(node, false));
|
||||
delete animatingNodesRef.current[key];
|
||||
}, 1000); // 1 second animation
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up markers
|
||||
Object.values(markersRef.current).forEach(marker => marker.setMap(null));
|
||||
|
||||
// Clean up any pending animations
|
||||
Object.values(animatingNodesRef.current).forEach(timeoutId =>
|
||||
window.clearTimeout(timeoutId)
|
||||
);
|
||||
|
||||
// Close info window
|
||||
if (infoWindowRef.current) {
|
||||
infoWindowRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper function to initialize the map
|
||||
function initializeMap(element: HTMLDivElement) {
|
||||
const mapOptions: google.maps.MapOptions = {
|
||||
zoom: 10,
|
||||
mapTypeId: google.maps.MapTypeId.HYBRID,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: false,
|
||||
zoomControl: true,
|
||||
styles: [
|
||||
{
|
||||
featureType: "all",
|
||||
elementType: "labels.text.fill",
|
||||
stylers: [{ color: "#ffffff" }],
|
||||
},
|
||||
{
|
||||
featureType: "all",
|
||||
elementType: "labels.text.stroke",
|
||||
stylers: [{ visibility: "off" }],
|
||||
},
|
||||
{
|
||||
featureType: "administrative",
|
||||
elementType: "geometry",
|
||||
stylers: [{ visibility: "on" }, { color: "#2d2d2d" }],
|
||||
},
|
||||
{
|
||||
featureType: "landscape",
|
||||
elementType: "geometry",
|
||||
stylers: [{ color: "#1a1a1a" }],
|
||||
},
|
||||
{
|
||||
featureType: "poi",
|
||||
elementType: "geometry",
|
||||
stylers: [{ color: "#1a1a1a" }],
|
||||
},
|
||||
{
|
||||
featureType: "road",
|
||||
elementType: "geometry.fill",
|
||||
stylers: [{ color: "#2d2d2d" }],
|
||||
},
|
||||
{
|
||||
featureType: "road",
|
||||
elementType: "geometry.stroke",
|
||||
stylers: [{ color: "#333333" }],
|
||||
},
|
||||
{
|
||||
featureType: "water",
|
||||
elementType: "geometry",
|
||||
stylers: [{ color: "#0f252e" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mapInstanceRef.current = new google.maps.Map(element, mapOptions);
|
||||
infoWindowRef.current = new google.maps.InfoWindow();
|
||||
}
|
||||
|
||||
// Helper function to update node markers on the map
|
||||
function updateNodeMarkers(nodes: any[], navigate: any) {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Clear the bounds for recalculation
|
||||
boundsRef.current = new google.maps.LatLngBounds();
|
||||
const allKeys = new Set<string>();
|
||||
|
||||
// Update markers for each node with position
|
||||
nodes.forEach(node => {
|
||||
const key = `node-${node.id}`;
|
||||
allKeys.add(key);
|
||||
|
||||
// Convert coordinates to lat/lng
|
||||
const lat = node.position.latitudeI / 10000000;
|
||||
const lng = node.position.longitudeI / 10000000;
|
||||
const position = { lat, lng };
|
||||
|
||||
// Extend bounds to include this point
|
||||
boundsRef.current.extend(position);
|
||||
|
||||
// Get node name
|
||||
const nodeName = node.shortName || node.longName ||
|
||||
`${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`;
|
||||
|
||||
// Create or update marker
|
||||
if (!markersRef.current[key]) {
|
||||
createMarker(node, position, nodeName, navigate);
|
||||
} else {
|
||||
updateMarker(node, position);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove markers that don't exist in the current data set
|
||||
Object.keys(markersRef.current).forEach(key => {
|
||||
if (!allKeys.has(key)) {
|
||||
markersRef.current[key].setMap(null);
|
||||
delete markersRef.current[key];
|
||||
}
|
||||
});
|
||||
|
||||
// If we have nodes, fit the map to show all of them
|
||||
if (nodes.length > 0) {
|
||||
// Fit the bounds to see all nodes
|
||||
mapInstanceRef.current?.fitBounds(boundsRef.current);
|
||||
|
||||
// If we only have one node, ensure we're not too zoomed in
|
||||
if (nodes.length === 1 && mapInstanceRef.current) {
|
||||
setTimeout(() => {
|
||||
if (mapInstanceRef.current) {
|
||||
const currentZoom = mapInstanceRef.current.getZoom() || 15;
|
||||
mapInstanceRef.current.setZoom(Math.min(currentZoom, 15));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new marker
|
||||
function createMarker(node: any, position: google.maps.LatLngLiteral,
|
||||
nodeName: string, navigate: any) {
|
||||
if (!mapInstanceRef.current || !infoWindowRef.current) return;
|
||||
|
||||
const key = `node-${node.id}`;
|
||||
const marker = new google.maps.Marker({
|
||||
position,
|
||||
map: mapInstanceRef.current,
|
||||
title: nodeName,
|
||||
icon: getMarkerIcon(node),
|
||||
zIndex: node.isGateway ? 10 : 5, // Make gateways appear on top
|
||||
});
|
||||
|
||||
// Add click listener to show info window
|
||||
marker.addListener("click", () => {
|
||||
showInfoWindow(node, marker, navigate);
|
||||
});
|
||||
|
||||
markersRef.current[key] = marker;
|
||||
}
|
||||
|
||||
// Update an existing marker
|
||||
function updateMarker(node: any, position: google.maps.LatLngLiteral) {
|
||||
const key = `node-${node.id}`;
|
||||
markersRef.current[key].setPosition(position);
|
||||
markersRef.current[key].setIcon(getMarkerIcon(node));
|
||||
}
|
||||
|
||||
// Show info window for a node
|
||||
function showInfoWindow(node: any, marker: google.maps.Marker, navigate: any) {
|
||||
if (!infoWindowRef.current || !mapInstanceRef.current) return;
|
||||
|
||||
const nodeName = node.shortName || node.longName ||
|
||||
`${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`;
|
||||
|
||||
const secondsAgo = Math.floor(Date.now() / 1000) - node.lastHeard;
|
||||
let lastSeenText = formatLastSeen(secondsAgo);
|
||||
|
||||
const infoContent = `
|
||||
<div style="font-family: sans-serif; max-width: 240px; color: #181818;">
|
||||
<h3 style="margin: 0 0 8px; font-size: 16px; color: ${node.isGateway ? '#f97316' : '#16a34a'}; font-weight: 600;">
|
||||
${nodeName}
|
||||
</h3>
|
||||
<div style="font-size: 12px; color: #555; margin-bottom: 8px; font-weight: 500;">
|
||||
${node.isGateway ? 'Gateway' : 'Node'} · ID: ${node.id.toString(16)}
|
||||
</div>
|
||||
<div style="font-size: 12px; margin-bottom: 4px; color: #333;">
|
||||
Last seen: ${lastSeenText}
|
||||
</div>
|
||||
<div style="font-size: 12px; margin-bottom: 8px; color: #333;">
|
||||
Messages: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}
|
||||
</div>
|
||||
<a href="javascript:void(0);"
|
||||
id="view-node-${node.id}"
|
||||
style="font-size: 13px; color: #3b82f6; text-decoration: none; font-weight: 500; display: inline-block; padding: 4px 8px; background-color: #f1f5f9; border-radius: 4px;">
|
||||
View details →
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
infoWindowRef.current.setContent(infoContent);
|
||||
infoWindowRef.current.open(mapInstanceRef.current, marker);
|
||||
|
||||
// Add listener for the "View details" link with a delay to allow DOM to update
|
||||
setTimeout(() => {
|
||||
const link = document.getElementById(`view-node-${node.id}`);
|
||||
if (link) {
|
||||
link.addEventListener('click', () => {
|
||||
navigate({ to: `/node/$nodeId`, params: { nodeId: node.id.toString() } });
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full overflow-hidden effect-inset rounded-lg"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to determine if a node has valid position data
|
||||
function hasValidPosition(node: any) {
|
||||
return node.position &&
|
||||
node.position.latitudeI !== undefined &&
|
||||
node.position.longitudeI !== undefined;
|
||||
}
|
||||
|
||||
// Get a list of nodes that have position data
|
||||
function getNodesWithPosition(nodes: any, gateways: any) {
|
||||
const nodesMap = new Map(); // Use a Map to avoid duplicates
|
||||
|
||||
// Regular nodes
|
||||
Object.entries(nodes).forEach(([nodeIdStr, nodeData]) => {
|
||||
if (hasValidPosition(nodeData)) {
|
||||
const nodeId = parseInt(nodeIdStr);
|
||||
nodesMap.set(nodeId, {
|
||||
...nodeData,
|
||||
id: nodeId,
|
||||
isGateway: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Gateways with position data
|
||||
Object.entries(gateways).forEach(([gatewayId, gatewayData]) => {
|
||||
// Extract node ID from gateway ID (removing the '!' prefix)
|
||||
const nodeId = parseInt(gatewayId.substring(1), 16);
|
||||
|
||||
// First priority: Use gateway's mapReport position if available
|
||||
if (gatewayData.mapReport &&
|
||||
gatewayData.mapReport.latitudeI !== undefined &&
|
||||
gatewayData.mapReport.longitudeI !== undefined) {
|
||||
|
||||
nodesMap.set(nodeId, {
|
||||
...(nodesMap.get(nodeId) || {}), // Keep existing node data if any
|
||||
id: nodeId,
|
||||
isGateway: true,
|
||||
gatewayId: gatewayId,
|
||||
position: {
|
||||
latitudeI: gatewayData.mapReport.latitudeI,
|
||||
longitudeI: gatewayData.mapReport.longitudeI,
|
||||
precisionBits: gatewayData.mapReport.positionPrecision
|
||||
},
|
||||
// Include other gateway data
|
||||
lastHeard: gatewayData.lastHeard || (nodesMap.get(nodeId)?.lastHeard),
|
||||
messageCount: gatewayData.messageCount || (nodesMap.get(nodeId)?.messageCount || 0),
|
||||
textMessageCount: gatewayData.textMessageCount || (nodesMap.get(nodeId)?.textMessageCount || 0),
|
||||
shortName: gatewayData.shortName || (nodesMap.get(nodeId)?.shortName),
|
||||
longName: gatewayData.longName || (nodesMap.get(nodeId)?.longName)
|
||||
});
|
||||
}
|
||||
// Second priority: Mark existing node as gateway if it already has position data
|
||||
else if (nodesMap.has(nodeId)) {
|
||||
const existingNode = nodesMap.get(nodeId);
|
||||
nodesMap.set(nodeId, {
|
||||
...existingNode,
|
||||
isGateway: true,
|
||||
gatewayId: gatewayId,
|
||||
// Merge other data
|
||||
lastHeard: Math.max(existingNode.lastHeard || 0, gatewayData.lastHeard || 0),
|
||||
messageCount: existingNode.messageCount || gatewayData.messageCount || 0,
|
||||
textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 0,
|
||||
shortName: existingNode.shortName || gatewayData.shortName,
|
||||
longName: existingNode.longName || gatewayData.longName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(nodesMap.values());
|
||||
}
|
||||
|
||||
// Get marker icon for a node
|
||||
function getMarkerIcon(node: any, isAnimating: boolean = false) {
|
||||
return {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: isAnimating ? 14 : 10, // Increase size during animation
|
||||
fillColor: node.isGateway ? "#fb923c" : "#4ade80", // Orange for gateways, green for nodes
|
||||
fillOpacity: isAnimating ? 0.8 : 1, // Slightly transparent during animation
|
||||
strokeColor: isAnimating ? "#ffffff" : (node.isGateway ? "#f97316" : "#22c55e"),
|
||||
strokeWeight: isAnimating ? 3 : 2, // Thicker stroke during animation
|
||||
};
|
||||
}
|
||||
|
||||
// Format the "last seen" text
|
||||
function formatLastSeen(secondsAgo: number) {
|
||||
if (secondsAgo < 60) {
|
||||
return `${secondsAgo} seconds ago`;
|
||||
} else if (secondsAgo < 3600) {
|
||||
const minutes = Math.floor(secondsAgo / 60);
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
} else if (secondsAgo < 86400) {
|
||||
const hours = Math.floor(secondsAgo / 3600);
|
||||
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
const days = Math.floor(secondsAgo / 86400);
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React, { useCallback } from "react";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { PacketRenderer } from "../packets/PacketRenderer";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { Separator } from "../Separator";
|
||||
|
||||
interface NodePacketListProps {
|
||||
/** Node ID to filter packets by */
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './ChannelDetail';
|
||||
export * from './BatteryLevel';
|
||||
export * from './SignalStrength';
|
||||
export * from './GoogleMap';
|
||||
export * from './NetworkMap';
|
||||
export * from './NodePacketList';
|
||||
export * from './NodePositionData';
|
||||
export * from './EnvironmentMetrics';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as RootImport } from './routes/root'
|
||||
import { Route as PacketsImport } from './routes/packets'
|
||||
import { Route as MapImport } from './routes/map'
|
||||
import { Route as HomeImport } from './routes/home'
|
||||
import { Route as DemoImport } from './routes/demo'
|
||||
import { Route as ChannelsImport } from './routes/channels'
|
||||
@@ -34,6 +35,12 @@ const PacketsRoute = PacketsImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const MapRoute = MapImport.update({
|
||||
id: '/map',
|
||||
path: '/map',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const HomeRoute = HomeImport.update({
|
||||
id: '/home',
|
||||
path: '/home',
|
||||
@@ -102,6 +109,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HomeImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/map': {
|
||||
id: '/map'
|
||||
path: '/map'
|
||||
fullPath: '/map'
|
||||
preLoaderRoute: typeof MapImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/packets': {
|
||||
id: '/packets'
|
||||
path: '/packets'
|
||||
@@ -140,6 +154,7 @@ export interface FileRoutesByFullPath {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/map': typeof MapRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
@@ -151,6 +166,7 @@ export interface FileRoutesByTo {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/map': typeof MapRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
@@ -163,6 +179,7 @@ export interface FileRoutesById {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/map': typeof MapRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
@@ -176,6 +193,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/map'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
@@ -186,6 +204,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/map'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
@@ -196,6 +215,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/map'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
@@ -208,6 +228,7 @@ export interface RootRouteChildren {
|
||||
ChannelsRoute: typeof ChannelsRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
HomeRoute: typeof HomeRoute
|
||||
MapRoute: typeof MapRoute
|
||||
PacketsRoute: typeof PacketsRoute
|
||||
RootRoute: typeof RootRoute
|
||||
ChannelChannelIdRoute: typeof ChannelChannelIdRoute
|
||||
@@ -219,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ChannelsRoute: ChannelsRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
HomeRoute: HomeRoute,
|
||||
MapRoute: MapRoute,
|
||||
PacketsRoute: PacketsRoute,
|
||||
RootRoute: RootRoute,
|
||||
ChannelChannelIdRoute: ChannelChannelIdRoute,
|
||||
@@ -239,6 +261,7 @@ export const routeTree = rootRoute
|
||||
"/channels",
|
||||
"/demo",
|
||||
"/home",
|
||||
"/map",
|
||||
"/packets",
|
||||
"/root",
|
||||
"/channel/$channelId",
|
||||
@@ -257,6 +280,9 @@ export const routeTree = rootRoute
|
||||
"/home": {
|
||||
"filePath": "home.tsx"
|
||||
},
|
||||
"/map": {
|
||||
"filePath": "map.tsx"
|
||||
},
|
||||
"/packets": {
|
||||
"filePath": "packets.tsx"
|
||||
},
|
||||
|
||||
31
web/src/routes/map.tsx
Normal file
31
web/src/routes/map.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PageWrapper } from "../components";
|
||||
import { NetworkMap } from "../components/dashboard";
|
||||
|
||||
export const Route = createFileRoute("/map")({
|
||||
component: MapPage,
|
||||
});
|
||||
|
||||
function MapPage() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-6xl">
|
||||
<div>
|
||||
<NetworkMap height="600px" />
|
||||
|
||||
<div className="mt-2 bg-neutral-800/50 rounded-lg p-2 text-xs text-neutral-400 effect-inset">
|
||||
<span className="inline-flex items-center mx-2 px-2 py-0.5 rounded text-green-500 bg-green-900/30">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-1.5"></span>
|
||||
Nodes
|
||||
</span>
|
||||
<span className="inline-flex items-center mx-2 px-2 py-0.5 rounded text-amber-500 bg-amber-900/30">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full mr-1.5"></span>
|
||||
Gateways
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user