From f83e6a9c31485750ee393be68f1a349c61845da5 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Wed, 30 Apr 2025 09:16:44 -0700 Subject: [PATCH] Consistent colors and activity status --- main.go | 4 +- web/src/components/dashboard/GatewayList.tsx | 7 - web/src/components/dashboard/MeshCard.tsx | 29 +-- web/src/components/dashboard/NetworkMap.tsx | 43 ++--- web/src/components/dashboard/NodeDetail.tsx | 103 ++++++----- web/src/components/dashboard/NodeList.tsx | 12 -- web/src/lib/activity.ts | 181 +++++++++++++++++++ web/src/routes/map.tsx | 23 ++- 8 files changed, 280 insertions(+), 122 deletions(-) create mode 100644 web/src/lib/activity.ts diff --git a/main.go b/main.go index 1ae718f..493d8cc 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/web/src/components/dashboard/GatewayList.tsx b/web/src/components/dashboard/GatewayList.tsx index ec5df2c..57ac3dc 100644 --- a/web/src/components/dashboard/GatewayList.tsx +++ b/web/src/components/dashboard/GatewayList.tsx @@ -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} /> ); diff --git a/web/src/components/dashboard/MeshCard.tsx b/web/src/components/dashboard/MeshCard.tsx index 11f0679..7a325d8 100644 --- a/web/src/components/dashboard/MeshCard.tsx +++ b/web/src/components/dashboard/MeshCard.tsx @@ -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 = ({ nodeData, observedNodes = [], onClick, - isActive = false, - isRecent = false, lastHeard, }) => { // Format last heard time @@ -44,11 +41,15 @@ export const MeshCard: React.FC = ({ ); }; + // 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 = ({ // 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 ( diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index a6969b1..1e4a119 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -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 = `
-

+

${nodeName}

${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}
-
- Last seen: ${lastSeenText} +
+ + ${statusText} - Last seen: ${lastSeenText}
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`; - } -} \ No newline at end of file diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 40fbe4d..61e371d 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -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 = ({ 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 = ({ 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 = ({ nodeId }) => {
{node.isGateway ? ( @@ -186,12 +190,15 @@ export const NodeDetail: React.FC = ({ nodeId }) => {
- {isActive ? "Active" : "Inactive"} - last seen {lastSeenText} + {statusText} - last seen {lastSeenText}
-
+
!{nodeId.toString(16)}
@@ -258,20 +265,20 @@ export const NodeDetail: React.FC = ({ nodeId }) => { {/* Show MapReport-specific information for gateways */} {node.isGateway && (
-
- +
+ Gateway Node {node.observedNodeCount !== undefined && ( - + {node.observedNodeCount}{" "} {node.observedNodeCount === 1 ? "node" : "nodes"} )} {node.mapReport?.numOnlineLocalNodes !== undefined && ( - + {node.mapReport.numOnlineLocalNodes} online local nodes )} diff --git a/web/src/components/dashboard/NodeList.tsx b/web/src/components/dashboard/NodeList.tsx index fb030c3..767f1d7 100644 --- a/web/src/components/dashboard/NodeList.tsx +++ b/web/src/components/dashboard/NodeList.tsx @@ -57,16 +57,6 @@ export const NodeList: React.FC = () => {
) : ( 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 ( { nodeId={node.nodeId} nodeData={node} onClick={handleNodeClick} - isRecent={isRecent} - isActive={isActive} lastHeard={node.lastHeard} /> ); diff --git a/web/src/lib/activity.ts b/web/src/lib/activity.ts new file mode 100644 index 0000000..fb19500 --- /dev/null +++ b/web/src/lib/activity.ts @@ -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 + }; +} \ No newline at end of file diff --git a/web/src/routes/map.tsx b/web/src/routes/map.tsx index 52b32d4..19411b2 100644 --- a/web/src/routes/map.tsx +++ b/web/src/routes/map.tsx @@ -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() { />
-
- - - Nodes - - - - Gateways - +
+
+ + + Nodes + + + + Gateways + +
+ +
{/* Always show the button, but disable it when auto-zoom is enabled */}