forked from iarv/meshstream
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:
18
Makefile
18
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
|
||||
|
||||
2
main.go
2
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()
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
web/src/components/dashboard/RouterList.tsx
Normal file
66
web/src/components/dashboard/RouterList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './NodeList';
|
||||
export * from './GatewayList';
|
||||
export * from './RouterList';
|
||||
export * from './MeshCard';
|
||||
export * from './NodeDetail';
|
||||
export * from './ChannelDetail';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user