diff --git a/Makefile b/Makefile index 14e0ee4..898bfe8 100644 --- a/Makefile +++ b/Makefile @@ -97,14 +97,16 @@ web-lint: # Build a Docker image docker-build: - docker build \ - --build-arg MESHSTREAM_API_BASE_URL=$${MESHSTREAM_API_BASE_URL:-} \ - --build-arg MESHSTREAM_APP_ENV=$${MESHSTREAM_APP_ENV:-production} \ - --build-arg MESHSTREAM_SITE_TITLE=$${MESHSTREAM_SITE_TITLE:-Meshstream} \ - --build-arg MESHSTREAM_SITE_DESCRIPTION=$${MESHSTREAM_SITE_DESCRIPTION:-"Meshtastic activity monitoring"} \ - --build-arg MESHSTREAM_GOOGLE_MAPS_ID=$${MESHSTREAM_GOOGLE_MAPS_ID:-4f089fb2d9fbb3db} \ - --build-arg MESHSTREAM_GOOGLE_MAPS_API_KEY=$${MESHSTREAM_GOOGLE_MAPS_API_KEY} \ - -t meshstream . + docker buildx build \ + --build-arg "MESHSTREAM_API_BASE_URL=$${MESHSTREAM_API_BASE_URL:-}" \ + --build-arg "MESHSTREAM_APP_ENV=$${MESHSTREAM_APP_ENV:-production}" \ + --build-arg "MESHSTREAM_SITE_TITLE=$${MESHSTREAM_SITE_TITLE:-Meshstream}" \ + --build-arg "MESHSTREAM_SITE_DESCRIPTION=$${MESHSTREAM_SITE_DESCRIPTION:-Meshtastic activity monitoring}" \ + --build-arg "MESHSTREAM_GOOGLE_MAPS_ID=$${MESHSTREAM_GOOGLE_MAPS_ID:-4f089fb2d9fbb3db}" \ + --build-arg "MESHSTREAM_GOOGLE_MAPS_API_KEY=$${MESHSTREAM_GOOGLE_MAPS_API_KEY:-}" \ + --load \ + -t meshstream \ + . # Run Docker container with environment variables docker-run: docker-build diff --git a/main.go b/main.go index 5b4ff3d..add494d 100644 --- a/main.go +++ b/main.go @@ -92,7 +92,7 @@ func parseConfig() *Config { channelKeysDefault := getEnv("CHANNEL_KEYS", "LongFast:"+decoder.DefaultPrivateKey) channelKeysFlag := flag.String("channel-keys", channelKeysDefault, "Comma-separated list of channel:key pairs for encrypted channels") - flag.IntVar(&config.CacheSize, "cache-size", intFromEnv("CACHE_SIZE", 50), "Number of packets to cache for new subscribers") + flag.IntVar(&config.CacheSize, "cache-size", intFromEnv("CACHE_SIZE", 200), "Number of packets to cache for new subscribers") flag.BoolVar(&config.VerboseLogging, "verbose", boolFromEnv("VERBOSE_LOGGING", false), "Enable verbose message logging") flag.Parse() diff --git a/web/src/components/dashboard/MeshCard.tsx b/web/src/components/dashboard/MeshCard.tsx index eacd0fb..85643cc 100644 --- a/web/src/components/dashboard/MeshCard.tsx +++ b/web/src/components/dashboard/MeshCard.tsx @@ -1,12 +1,12 @@ import React from "react"; -import { Battery, MapPin, Thermometer, Network, BoomBox } from "lucide-react"; +import { Battery, MapPin, Thermometer, Network, BoomBox, Router } from "lucide-react"; import { Counter } from "../Counter"; import { NodeData } from "../../store/slices/aggregatorSlice"; import { getActivityLevel, getNodeColors } from "../../lib/activity"; import { cn } from "@/lib/cn"; export interface MeshCardProps { - type: "node" | "gateway"; + type: "node" | "gateway" | "router"; nodeId: number; nodeData: NodeData; observedNodes?: number[]; @@ -35,16 +35,18 @@ export const MeshCard: React.FC = ({ // Get icon based on type const getIcon = () => { - return type === "gateway" ? ( - - ) : ( - - ); + if (type === "gateway") { + return ; + } else if (type === "router") { + return ; + } else { + return ; + } }; // Use activity helpers to get styles - const activityLevel = getActivityLevel(lastHeard, type === "gateway"); - const colors = getNodeColors(activityLevel, type === "gateway"); + const activityLevel = getActivityLevel(lastHeard, type === "gateway", type === "router"); + const colors = getNodeColors(activityLevel, type === "gateway", type === "router"); // Get icon style based on activity const getIconStyle = () => { diff --git a/web/src/components/dashboard/NodeList.tsx b/web/src/components/dashboard/NodeList.tsx index cd492be..955f0af 100644 --- a/web/src/components/dashboard/NodeList.tsx +++ b/web/src/components/dashboard/NodeList.tsx @@ -10,7 +10,7 @@ export const NodeList: React.FC = () => { // Create a set of node IDs that are already shown as gateways const gatewayNodeIds = new Set(); - + // Extract node IDs from gateway IDs Object.keys(gateways).forEach(gatewayId => { const nodeIdMatch = gatewayId.match(/^!([0-9a-f]+)/i); @@ -21,8 +21,11 @@ export const NodeList: React.FC = () => { } }); - // Convert nodes object to array and filter out gateway nodes - const nodeArray = Object.values(nodes).filter(node => !gatewayNodeIds.has(node.nodeId)); + // Convert nodes object to array and filter out gateway nodes and routers + const nodeArray = Object.values(nodes).filter(node => + !gatewayNodeIds.has(node.nodeId) && + node.role !== "ROUTER" + ); // Sort by node ID (stable) const sortedNodes = nodeArray.sort((a, b) => a.nodeId - b.nodeId); @@ -47,8 +50,8 @@ export const NodeList: React.FC = () => {
- {Object.keys(nodes).length > 0 ? - "All nodes are shown as gateways above" : + {Object.keys(nodes).length > 0 ? + "All nodes are shown as gateways or routers above" : "Waiting for node data..."}
diff --git a/web/src/components/dashboard/RouterList.tsx b/web/src/components/dashboard/RouterList.tsx new file mode 100644 index 0000000..4367497 --- /dev/null +++ b/web/src/components/dashboard/RouterList.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useAppSelector } from "../../hooks"; +import { RefreshCw } from "lucide-react"; +import { MeshCard } from "./MeshCard"; + +export const RouterList: React.FC = () => { + const { nodes } = useAppSelector((state) => state.aggregator); + const navigate = useNavigate(); + + // Filter nodes that are routers (role === "ROUTER") + // Exclude nodes that are already shown as gateways + const routerArray = Object.values(nodes).filter( + (node) => node.role === "ROUTER" && !node.isGateway + ); + + // Sort by last heard time (most recent first) + const sortedRouters = routerArray.sort((a, b) => { + return b.lastHeard - a.lastHeard; + }); + + const handleNodeClick = (clickedNodeId: number) => { + navigate({ to: "/node/$nodeId", params: { nodeId: clickedNodeId.toString(16) } }); + }; + + return ( +
+
+

+ Routers +

+
+ {routerArray.length}{" "} + {routerArray.length === 1 ? "router" : "routers"} +
+
+
+ {routerArray.length === 0 ? ( +
+
+ +
+
+
+ Waiting for router data... +
+
+
+ ) : ( + sortedRouters.map((router) => { + return ( + + ); + }) + )} +
+
+ ); +}; diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts index 3b620a9..d3aef36 100644 --- a/web/src/components/dashboard/index.ts +++ b/web/src/components/dashboard/index.ts @@ -1,5 +1,6 @@ export * from './NodeList'; export * from './GatewayList'; +export * from './RouterList'; export * from './MeshCard'; export * from './NodeDetail'; export * from './ChannelDetail'; diff --git a/web/src/lib/activity.ts b/web/src/lib/activity.ts index 40f41ca..7bfd8a9 100644 --- a/web/src/lib/activity.ts +++ b/web/src/lib/activity.ts @@ -12,19 +12,24 @@ export enum ActivityLevel { // Node types export enum NodeType { NODE = 'node', - GATEWAY = 'gateway' + GATEWAY = 'gateway', + ROUTER = 'router' } // Allow different time thresholds for different node types in seconds export const TIME_THRESHOLDS = { [NodeType.NODE]: { recent: 600, // 10 minutes - active: 1800, // 30 minutes + active: 3600, // 60 minutes }, [NodeType.GATEWAY]: { recent: 600, // 10 minutes active: 1800, // 30 minutes }, + [NodeType.ROUTER]: { + recent: 600, // 10 minutes + active: 43200, // 12 hours + }, }; // Color schemes for different node types @@ -59,8 +64,8 @@ export const COLORS = { }, }, [NodeType.NODE]: { - [ActivityLevel.RECENT]: { - "fill": "#93c5fd", + [ActivityLevel.RECENT]: { + "fill": "#93c5fd", "stroke": "#60a5fa", "text": "#93c5fd", "background": "bg-blue-900/30", @@ -68,8 +73,8 @@ export const COLORS = { "bgClass": "bg-blue-500", "statusDot": "bg-blue-500" }, - [ActivityLevel.ACTIVE]: { - "fill": "#3b82f6", + [ActivityLevel.ACTIVE]: { + "fill": "#3b82f6", "stroke": "#2563eb", "text": "#3b82f6", "background": "bg-blue-900/50", @@ -77,8 +82,37 @@ export const COLORS = { "bgClass": "bg-blue-700", "statusDot": "bg-blue-700" }, - [ActivityLevel.INACTIVE]: { - "fill": "#9ca3af", + [ActivityLevel.INACTIVE]: { + "fill": "#9ca3af", + "stroke": "#6b7280", + "text": "#6b7280", + "background": "bg-neutral-700/30", + "textClass": "text-neutral-500", + "bgClass": "bg-neutral-500", + "statusDot": "bg-neutral-500" + } + }, + [NodeType.ROUTER]: { + [ActivityLevel.RECENT]: { + "fill": "#fbbf24", + "stroke": "#f59e0b", + "text": "#fbbf24", + "background": "bg-yellow-900/30", + "textClass": "text-yellow-500", + "bgClass": "bg-yellow-500", + "statusDot": "bg-yellow-500" + }, + [ActivityLevel.ACTIVE]: { + "fill": "#f59e0b", + "stroke": "#d97706", + "text": "#f59e0b", + "background": "bg-yellow-900/50", + "textClass": "text-yellow-700", + "bgClass": "bg-yellow-700", + "statusDot": "bg-yellow-700" + }, + [ActivityLevel.INACTIVE]: { + "fill": "#9ca3af", "stroke": "#6b7280", "text": "#6b7280", "background": "bg-neutral-700/30", @@ -98,17 +132,18 @@ export const STATUS_TEXT = { /** * Determines the activity level of a node based on its last heard time - * + * * @param lastHeardTimestamp UNIX timestamp in seconds * @param isGateway Whether the node is a gateway + * @param isRouter Whether the node is a router * @returns The activity level (RECENT, ACTIVE, or INACTIVE) */ -export function getActivityLevel(lastHeardTimestamp?: number, isGateway = false): ActivityLevel { +export function getActivityLevel(lastHeardTimestamp?: number, isGateway = false, isRouter = false): ActivityLevel { if (!lastHeardTimestamp) return ActivityLevel.INACTIVE; - - const nodeType = isGateway ? NodeType.GATEWAY : NodeType.NODE; + + const nodeType = isGateway ? NodeType.GATEWAY : (isRouter ? NodeType.ROUTER : NodeType.NODE); const secondsSince = Math.floor(Date.now() / 1000) - lastHeardTimestamp; - + if (secondsSince < TIME_THRESHOLDS[nodeType].recent) { return ActivityLevel.RECENT; } else if (secondsSince < TIME_THRESHOLDS[nodeType].active) { @@ -120,13 +155,14 @@ export function getActivityLevel(lastHeardTimestamp?: number, isGateway = false) /** * Returns the color scheme for a node based on its activity level - * + * * @param activityLevel The activity level * @param isGateway Whether the node is a gateway + * @param isRouter Whether the node is a router * @returns Color scheme object */ -export function getNodeColors(activityLevel: ActivityLevel, isGateway = false): typeof COLORS[NodeType.NODE][ActivityLevel.RECENT] { - const nodeType = isGateway ? NodeType.GATEWAY : NodeType.NODE; +export function getNodeColors(activityLevel: ActivityLevel, isGateway = false, isRouter = false): typeof COLORS[NodeType.NODE][ActivityLevel.RECENT] { + const nodeType = isGateway ? NodeType.GATEWAY : (isRouter ? NodeType.ROUTER : NodeType.NODE); return COLORS[nodeType][activityLevel]; } @@ -163,16 +199,17 @@ export function formatLastSeen(secondsAgo: number): string { /** * Gets style classes based on the activity level - * + * * @param lastHeardTimestamp UNIX timestamp in seconds * @param isGateway Whether the node is a gateway + * @param isRouter Whether the node is a router * @returns Object with color classes for various UI elements */ -export function getActivityStyles(lastHeardTimestamp?: number, isGateway = false) { - const activityLevel = getActivityLevel(lastHeardTimestamp, isGateway); - const colors = getNodeColors(activityLevel, isGateway); +export function getActivityStyles(lastHeardTimestamp?: number, isGateway = false, isRouter = false) { + const activityLevel = getActivityLevel(lastHeardTimestamp, isGateway, isRouter); + const colors = getNodeColors(activityLevel, isGateway, isRouter); const statusText = getStatusText(activityLevel); - + return { activityLevel, statusText, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cad71a0..792d37d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -63,7 +63,7 @@ export function streamPackets( const MAX_RECONNECT_ATTEMPTS = 30; // Give up after this many attempts // Packet age settings - const MAX_PACKET_AGE_HOURS = 12; // Ignore packets older than this many hours + const MAX_PACKET_AGE_HOURS = 24; // Ignore packets older than this many hours /** * Calculate delay for exponential backoff diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index 2793161..cdeaf4d 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -1,4 +1,4 @@ -import { PageWrapper, NodeList, GatewayList } from "../components"; +import { PageWrapper, NodeList, GatewayList, RouterList } from "../components"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/home")({ @@ -13,6 +13,10 @@ function HomePage() { +
+ +
+
diff --git a/web/src/store/slices/aggregatorSlice.ts b/web/src/store/slices/aggregatorSlice.ts index db6a891..09ace43 100644 --- a/web/src/store/slices/aggregatorSlice.ts +++ b/web/src/store/slices/aggregatorSlice.ts @@ -90,7 +90,7 @@ const getChannelKey = (channelId: string): string => { }; // Maximum number of messages to keep per channel -const MAX_MESSAGES_PER_CHANNEL = 100; +const MAX_MESSAGES_PER_CHANNEL = 500; // Function to process a packet and update the state accordingly const processPacket = (state: AggregatorState, packet: Packet) => {