Consistent colors and activity status

This commit is contained in:
Daniel Pupius
2025-04-30 09:16:44 -07:00
parent 25c18b262f
commit f83e6a9c31
8 changed files with 280 additions and 122 deletions
+1 -3
View File
@@ -97,9 +97,7 @@ func main() {
// Process messages until interrupt received
logger.Info("Waiting for messages... Press Ctrl+C to exit")
logger.Info("Statistics will be printed every 30 seconds")
logger.Info("Messages will be logged to files in the ./logs directory")
logger.Infof("Web server running at http://%s:%s\n", serverHost, serverPort)
logger.Infof("Web server running at http://%s:%s", serverHost, serverPort)
// Wait for interrupt signal
<-sig
@@ -59,11 +59,6 @@ export const GatewayList: React.FC = () => {
matchingNode = nodes[nodeId];
}
// Determine if gateway is active (using stricter timeframe for gateways)
const secondsSinceLastHeard = Date.now() / 1000 - gateway.lastHeard;
const isRecent = secondsSinceLastHeard < 300; // 5 minutes for gateways
const isActive = !isRecent && secondsSinceLastHeard < 900; // 5-15 minutes for gateways
const handleNodeClick = (clickedNodeId: number) => {
navigate({ to: "/node/$nodeId", params: { nodeId: clickedNodeId.toString(16) } });
};
@@ -81,8 +76,6 @@ export const GatewayList: React.FC = () => {
}}
observedNodes={gateway.observedNodes}
onClick={handleNodeClick}
isRecent={isRecent}
isActive={isActive}
lastHeard={gateway.lastHeard}
/>
);
+9 -20
View File
@@ -2,6 +2,7 @@ import React from "react";
import { Radio, Signal, Battery, MapPin, Thermometer } from "lucide-react";
import { Counter } from "../Counter";
import { NodeData } from "../../store/slices/aggregatorSlice";
import { getActivityLevel, getNodeColors, ActivityLevel } from "../../lib/activity";
export interface MeshCardProps {
type: "node" | "gateway";
@@ -9,8 +10,6 @@ export interface MeshCardProps {
nodeData: NodeData;
observedNodes?: number[];
onClick?: (nodeId: number) => void;
isActive?: boolean;
isRecent?: boolean;
lastHeard: number;
}
@@ -20,8 +19,6 @@ export const MeshCard: React.FC<MeshCardProps> = ({
nodeData,
observedNodes = [],
onClick,
isActive = false,
isRecent = false,
lastHeard,
}) => {
// Format last heard time
@@ -44,11 +41,15 @@ export const MeshCard: React.FC<MeshCardProps> = ({
);
};
// Use activity helpers to get styles
const activityLevel = getActivityLevel(lastHeard, type === "gateway");
const colors = getNodeColors(activityLevel, type === "gateway");
// Get card style based on activity
const getCardStyle = () => {
if (isRecent) {
if (activityLevel === ActivityLevel.RECENT) {
return "bg-neutral-800 hover:bg-neutral-700";
} else if (isActive) {
} else if (activityLevel === ActivityLevel.ACTIVE) {
return "bg-neutral-800/80 hover:bg-neutral-700/80";
} else {
return "bg-neutral-800/50 hover:bg-neutral-800";
@@ -57,24 +58,12 @@ export const MeshCard: React.FC<MeshCardProps> = ({
// Get icon style based on activity
const getIconStyle = () => {
if (isRecent) {
return "bg-green-900/30 text-green-500";
} else if (isActive) {
return "bg-green-900/50 text-green-700";
} else {
return "bg-neutral-700/30 text-neutral-500";
}
return colors.background + " " + colors.textClass;
};
// Get status dot color
const getStatusDotStyle = () => {
if (isRecent) {
return "bg-green-500";
} else if (isActive) {
return "bg-green-700";
} else {
return "bg-neutral-500";
}
return colors.statusDot;
};
return (
+20 -23
View File
@@ -4,6 +4,7 @@ import { useAppSelector } from "../../hooks";
import { useNavigate } from "@tanstack/react-router";
import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice";
import { Position } from "../../lib/types";
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
interface NetworkMapProps {
/** Height of the map in CSS units */
@@ -361,22 +362,30 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
): void {
if (!infoWindowRef.current || !mapInstanceRef.current) return;
const nodeName = node.shortName || node.longName ||
`${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`;
const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`;
const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0;
const lastSeenText = formatLastSeen(secondsAgo);
// Get activity level and styles using the helper functions
const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
const colors = getNodeColors(activityLevel, node.isGateway);
const statusText = getStatusText(activityLevel);
// Use the dot color from our activity helper
const statusDotColor = colors.fill;
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;">
<h3 style="margin: 0 0 8px; font-size: 16px; color: ${statusDotColor}; font-weight: 600;">
${nodeName}
</h3>
<div style="font-size: 12px; color: #555; margin-bottom: 8px; font-weight: 500;">
${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}
</div>
<div style="font-size: 12px; margin-bottom: 4px; color: #333;">
Last seen: ${lastSeenText}
<div style="font-size: 12px; margin-bottom: 4px; color: #333; display: flex; align-items: center;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: ${statusDotColor}; margin-right: 6px;"></span>
<span>${statusText} - Last seen: ${lastSeenText}</span>
</div>
<div style="font-size: 12px; margin-bottom: 8px; color: #333;">
Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}
@@ -529,28 +538,16 @@ interface MarkerIconConfig {
// Get marker icon for a node
function getMarkerIcon(node: MapNode, isAnimating: boolean = false): MarkerIconConfig {
// Get activity level and colors using the helper functions
const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
const colors = getNodeColors(activityLevel, node.isGateway);
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
fillColor: colors.fill,
fillOpacity: isAnimating ? 0.8 : 1, // Slightly transparent during animation
strokeColor: isAnimating ? "#ffffff" : (node.isGateway ? "#f97316" : "#22c55e"),
strokeColor: isAnimating ? "#ffffff" : colors.stroke,
strokeWeight: isAnimating ? 3 : 2, // Thicker stroke during animation
};
}
// Format the "last seen" text
function formatLastSeen(secondsAgo: number): string {
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`;
}
}
+55 -48
View File
@@ -2,6 +2,8 @@ import React, { useEffect } from "react";
import { useNavigate, Link } from "@tanstack/react-router";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { selectNode } from "../../store/slices/aggregatorSlice";
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
import { cn } from "../../lib/cn";
import {
ArrowLeft,
Radio,
@@ -47,31 +49,40 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
const navigate = useNavigate();
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
// Construct the gateway ID format from the node ID
const gatewayId = `!${nodeId.toString(16).toLowerCase()}`;
// Check if there's a gateway with this ID
const gateway = gateways[gatewayId];
// First try to get the node directly from nodes collection
let node = nodes[nodeId];
// If node not found in nodes collection, check if it might be a gateway
if (!node) {
// Construct the gateway ID format from the node ID
const gatewayId = `!${nodeId.toString(16).toLowerCase()}`;
// Check if there's a gateway with this ID
const gateway = gateways[gatewayId];
if (gateway) {
// Create a synthetic node from the gateway data
node = {
nodeId,
lastHeard: gateway.lastHeard,
messageCount: gateway.messageCount,
textMessageCount: gateway.textMessageCount,
// Mark this as a gateway node
isGateway: true,
gatewayId: gatewayId,
// Add observed nodes info
observedNodeCount: gateway.observedNodes.length,
};
}
// If node exists but doesn't have isGateway set, check if it should be a gateway
if (node && !node.isGateway && gateway) {
// Update the node with gateway info
node = {
...node,
isGateway: true,
gatewayId: gatewayId,
observedNodeCount: gateway.observedNodes.length,
};
}
// If node not found in nodes collection, create a synthetic node from gateway data
if (!node && gateway) {
// Create a synthetic node from the gateway data
node = {
nodeId,
lastHeard: gateway.lastHeard,
messageCount: gateway.messageCount,
textMessageCount: gateway.textMessageCount,
// Mark this as a gateway node
isGateway: true,
gatewayId: gatewayId,
// Add observed nodes info
observedNodeCount: gateway.observedNodes.length,
};
}
useEffect(() => {
@@ -119,23 +130,12 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
// Calculate how recently node was active
const secondsAgo = Math.floor(Date.now() / 1000) - node.lastHeard;
const minutesAgo = Math.floor(secondsAgo / 60);
const hoursAgo = Math.floor(minutesAgo / 60);
const daysAgo = Math.floor(hoursAgo / 24);
let lastSeenText = "";
if (secondsAgo < 60) {
lastSeenText = `${secondsAgo} seconds ago`;
} else if (minutesAgo < 60) {
lastSeenText = `${minutesAgo} minute${minutesAgo > 1 ? "s" : ""} ago`;
} else if (hoursAgo < 24) {
lastSeenText = `${hoursAgo} hour${hoursAgo > 1 ? "s" : ""} ago`;
} else {
lastSeenText = `${daysAgo} day${daysAgo > 1 ? "s" : ""} ago`;
}
// Is node active
const isActive = secondsAgo < 600; // 10 minutes
// Use activity helpers
const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
const activityColors = getNodeColors(activityLevel, node.isGateway);
const statusText = getStatusText(activityLevel);
const lastSeenText = formatLastSeen(secondsAgo);
// Get position data if available
const hasPosition =
@@ -172,7 +172,11 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
<ArrowLeft className="w-4 h-4" />
</button>
<div
className={`p-2 mr-3 rounded-full ${isActive ? "bg-green-900/30 text-green-500" : "bg-neutral-700/30 text-neutral-500"} effect-inset`}
className={cn(
"p-2 mr-3 rounded-full effect-inset",
activityColors.background,
activityColors.textClass
)}
>
{node.isGateway ? (
<Signal className="w-4 h-4" />
@@ -186,12 +190,15 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</h1>
<div className="text-sm text-neutral-400 flex items-center">
<span
className={`inline-block w-2 h-2 rounded-full mr-2 ${isActive ? "bg-green-500" : "bg-neutral-500"}`}
className={cn("inline-block w-2 h-2 rounded-full mr-2", activityColors.statusDot)}
></span>
{isActive ? "Active" : "Inactive"} - last seen {lastSeenText}
{statusText} - last seen {lastSeenText}
</div>
</div>
<div className="text-sm bg-neutral-900/50 px-3 py-1.5 rounded font-mono text-green-400 effect-inset tracking-wider">
<div className={cn(
"text-sm bg-neutral-900/50 px-3 py-1.5 rounded font-mono effect-inset tracking-wider",
activityColors.textClass
)}>
!{nodeId.toString(16)}
</div>
</div>
@@ -258,20 +265,20 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
{/* Show MapReport-specific information for gateways */}
{node.isGateway && (
<div className="mt-4 pt-3 border-t border-neutral-700 space-y-3">
<div className="flex justify-between items-center mb-2 bg-blue-900/20 p-2 rounded effect-inset">
<span className="text-blue-400 flex items-center">
<div className="flex justify-between items-center mb-2 p-2 rounded effect-inset bg-neutral-700/50 ">
<span className="flex items-center">
<Signal className="w-4 h-4 mr-1.5" />
Gateway Node
</span>
{node.observedNodeCount !== undefined && (
<span className="text-blue-400 flex items-center">
<span className="flex items-center">
<Users className="w-4 h-4 mr-1.5" />
{node.observedNodeCount}{" "}
{node.observedNodeCount === 1 ? "node" : "nodes"}
</span>
)}
{node.mapReport?.numOnlineLocalNodes !== undefined && (
<span className="text-emerald-400 text-xs flex items-center font-mono">
<span className="text-xs flex items-center font-mono opacity-80">
{node.mapReport.numOnlineLocalNodes} online local nodes
</span>
)}
-12
View File
@@ -57,16 +57,6 @@ export const NodeList: React.FC = () => {
</div>
) : (
sortedNodes.map((node) => {
// Calculate time since last heard (in seconds)
const secondsSinceLastHeard = Date.now() / 1000 - node.lastHeard;
// Determine node activity status:
// Recent: < 10 minutes (green)
// Active: 10-30 minutes (blue)
// Inactive: > 30 minutes (grey)
const isRecent = secondsSinceLastHeard < 600; // 10 minutes
const isActive = !isRecent && secondsSinceLastHeard < 1800; // 10-30 minutes
return (
<MeshCard
key={node.nodeId}
@@ -74,8 +64,6 @@ export const NodeList: React.FC = () => {
nodeId={node.nodeId}
nodeData={node}
onClick={handleNodeClick}
isRecent={isRecent}
isActive={isActive}
lastHeard={node.lastHeard}
/>
);
+181
View File
@@ -0,0 +1,181 @@
/**
* Activity status and color utilities for Meshtastic nodes
*/
// Different activity levels
export enum ActivityLevel {
RECENT = 'recent', // Very recently seen
ACTIVE = 'active', // Active but not super recent
INACTIVE = 'inactive' // Not active for a while
}
// Node types
export enum NodeType {
NODE = 'node',
GATEWAY = 'gateway'
}
// Allow different time thresholds for different node types in seconds
export const TIME_THRESHOLDS = {
[NodeType.NODE]: {
recent: 600, // 10 minutes
active: 1800, // 30 minutes
},
[NodeType.GATEWAY]: {
recent: 600, // 10 minutes
active: 1800, // 30 minutes
},
};
// Color schemes for different node types
export const COLORS = {
[NodeType.NODE]: {
[ActivityLevel.RECENT]: {
fill: "#4ade80",
stroke: "#22c55e",
text: "#4ade80",
background: "bg-green-900/30",
textClass: "text-green-500",
bgClass: "bg-green-500",
statusDot: "bg-green-500"
},
[ActivityLevel.ACTIVE]: {
fill: "#16a34a",
stroke: "#15803d",
text: "#16a34a",
background: "bg-green-900/50",
textClass: "text-green-700",
bgClass: "bg-green-700",
statusDot: "bg-green-700"
},
[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.GATEWAY]: {
[ActivityLevel.RECENT]: {
"fill": "#93c5fd",
"stroke": "#60a5fa",
"text": "#93c5fd",
"background": "bg-blue-900/30",
"textClass": "text-blue-500",
"bgClass": "bg-blue-500",
"statusDot": "bg-blue-500"
},
[ActivityLevel.ACTIVE]: {
"fill": "#3b82f6",
"stroke": "#2563eb",
"text": "#3b82f6",
"background": "bg-blue-900/50",
"textClass": "text-blue-700",
"bgClass": "bg-blue-700",
"statusDot": "bg-blue-700"
},
[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"
}
},
};
// Status text for different activity levels
export const STATUS_TEXT = {
[ActivityLevel.RECENT]: 'Active',
[ActivityLevel.ACTIVE]: 'Recent',
[ActivityLevel.INACTIVE]: 'Inactive',
};
/**
* 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
* @returns The activity level (RECENT, ACTIVE, or INACTIVE)
*/
export function getActivityLevel(lastHeardTimestamp?: number, isGateway = false): ActivityLevel {
if (!lastHeardTimestamp) return ActivityLevel.INACTIVE;
const nodeType = isGateway ? NodeType.GATEWAY : 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) {
return ActivityLevel.ACTIVE;
} else {
return ActivityLevel.INACTIVE;
}
}
/**
* 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
* @returns Color scheme object
*/
export function getNodeColors(activityLevel: ActivityLevel, isGateway = false): typeof COLORS[NodeType.NODE][ActivityLevel.RECENT] {
const nodeType = isGateway ? NodeType.GATEWAY : NodeType.NODE;
return COLORS[nodeType][activityLevel];
}
/**
* Returns the status text for an activity level
*
* @param activityLevel The activity level
* @returns Status text
*/
export function getStatusText(activityLevel: ActivityLevel): string {
return STATUS_TEXT[activityLevel];
}
/**
* Formats a "last seen" time difference in a human-readable format
*
* @param secondsAgo Number of seconds since the event
* @returns Human-readable time string (e.g., "2 minutes ago")
*/
export function formatLastSeen(secondsAgo: number): string {
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`;
}
}
/**
* Gets style classes based on the activity level
*
* @param lastHeardTimestamp UNIX timestamp in seconds
* @param isGateway Whether the node is a gateway
* @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);
const statusText = getStatusText(activityLevel);
return {
activityLevel,
statusText,
...colors
};
}
+14 -9
View File
@@ -4,6 +4,7 @@ import { PageWrapper } from "../components";
import { NetworkMap } from "../components/dashboard";
import { Button } from "../components/ui";
import { Locate } from "lucide-react";
import { getNodeColors, ActivityLevel } from "../lib/activity";
export const Route = createFileRoute("/map")({
component: MapPage,
@@ -32,15 +33,19 @@ function MapPage() {
/>
<div className="mt-2 bg-neutral-800/50 rounded-lg p-2 text-xs flex items-center justify-between effect-inset">
<div>
<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 className="flex flex-wrap gap-2">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded ${getNodeColors(ActivityLevel.RECENT, false).textClass} ${getNodeColors(ActivityLevel.RECENT, false).background}`}>
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, false).statusDot} rounded-full mr-1.5`}></span>
Nodes
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded ${getNodeColors(ActivityLevel.RECENT, true).textClass} ${getNodeColors(ActivityLevel.RECENT, true).background}`}>
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, true).statusDot} rounded-full mr-1.5`}></span>
Gateways
</span>
</div>
</div>
{/* Always show the button, but disable it when auto-zoom is enabled */}