Split routers and extend mesh traffic retention (#1)

* Split routers on dashboard and extend retention periods

- Add Router node type with 12-hour stale timeout (vs 30 min for regular nodes)
- Create RouterList component to display router nodes separately
- Update NodeList to filter out routers (similar to gateways)
- Add yellow color scheme for router nodes to distinguish from gateways (green) and nodes (blue)
- Extend mesh traffic retention across the board:
  - Client-side packet age filter: 12h → 24h
  - Broker cache size: 50 → 200 packets
  - Messages per channel: 100 → 500

* Update regular node activity threshold to 60 minutes

* Fix TypeScript errors in router filtering logic

* Fix docker-build to use buildx explicitly with --load flag

* Add default empty value for MESHSTREAM_GOOGLE_MAPS_API_KEY in docker-build

* Restructure docker buildx command to fix path argument parsing

* Remove trailing backslash before build context path in docker-build

* Quote build args and separate path argument in docker-build
This commit is contained in:
Daniel Pupius
2026-01-06 12:25:34 -08:00
committed by GitHub
parent 245911a450
commit 69a31ca406
10 changed files with 162 additions and 47 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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<MeshCardProps> = ({
// Get icon based on type
const getIcon = () => {
return type === "gateway" ? (
<Network className="w-4 h-4" />
) : (
<BoomBox className="w-4 h-4" />
);
if (type === "gateway") {
return <Network className="w-4 h-4" />;
} else if (type === "router") {
return <Router className="w-4 h-4" />;
} else {
return <BoomBox className="w-4 h-4" />;
}
};
// 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 = () => {

View File

@@ -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<number>();
// 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 = () => {
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-400 truncate">
{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..."}
</div>
</div>

View File

@@ -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 (
<div className="space-y-1">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-semibold text-neutral-200">
Routers
</h2>
<div className="text-sm text-neutral-400 bg-neutral-800/70 px-2 py-0.5 rounded">
{routerArray.length}{" "}
{routerArray.length === 1 ? "router" : "routers"}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-2">
{routerArray.length === 0 ? (
<div className="bg-neutral-800/50 hover:bg-neutral-800 p-2 rounded-lg flex items-center">
<div className="p-1.5 rounded-full bg-neutral-700/30 text-neutral-500 mr-2">
<RefreshCw className="w-4 h-4 animate-spin" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-400 truncate">
Waiting for router data...
</div>
</div>
</div>
) : (
sortedRouters.map((router) => {
return (
<MeshCard
key={router.nodeId}
type="router"
nodeId={router.nodeId}
nodeData={router}
onClick={handleNodeClick}
lastHeard={router.lastHeard}
/>
);
})
)}
</div>
</div>
);
};

View File

@@ -1,5 +1,6 @@
export * from './NodeList';
export * from './GatewayList';
export * from './RouterList';
export * from './MeshCard';
export * from './NodeDetail';
export * from './ChannelDetail';

View File

@@ -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,

View File

@@ -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

View File

@@ -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() {
<GatewayList />
</div>
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
<RouterList />
</div>
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
<NodeList />
</div>

View File

@@ -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) => {