mirror of
https://github.com/dpup/meshstream.git
synced 2026-06-30 23:11:37 +02:00
Consistent colors and activity status
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user