Cache recent packets and send to client when it connects

This commit is contained in:
Daniel Pupius
2025-04-25 17:22:03 -07:00
parent 4817d31d39
commit 81892a5793
8 changed files with 750 additions and 341 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ func main() {
messagesChan := mqttClient.Messages()
// Create a message broker to distribute messages to multiple consumers
broker := mqtt.NewBroker(messagesChan, logger)
// Cache the last 50 packets for new subscribers
broker := mqtt.NewBroker(messagesChan, 50, logger)
// Create a stats tracker that subscribes to the broker
// with statistics printed every 30 seconds
+93 -6
View File
@@ -7,23 +7,90 @@ import (
meshtreampb "meshstream/generated/meshstream"
)
// CircularBuffer implements a fixed-size circular buffer for caching packets
type CircularBuffer struct {
buffer []*meshtreampb.Packet // Fixed size buffer to store packets
size int // Size of the buffer
next int // Index where the next packet will be stored
count int // Number of packets currently in the buffer
mutex sync.RWMutex // Lock for thread-safe access
}
// NewCircularBuffer creates a new circular buffer with the given size
func NewCircularBuffer(size int) *CircularBuffer {
return &CircularBuffer{
buffer: make([]*meshtreampb.Packet, size),
size: size,
next: 0,
count: 0,
}
}
// Add adds a packet to the circular buffer
func (cb *CircularBuffer) Add(packet *meshtreampb.Packet) {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.buffer[cb.next] = packet
cb.next = (cb.next + 1) % cb.size
// Update count if we haven't filled the buffer yet
if cb.count < cb.size {
cb.count++
}
}
// GetAll returns all packets in the buffer in chronological order
func (cb *CircularBuffer) GetAll() []*meshtreampb.Packet {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
if cb.count == 0 {
return []*meshtreampb.Packet{}
}
result := make([]*meshtreampb.Packet, cb.count)
// If buffer isn't full yet, just copy from start to next
if cb.count < cb.size {
copy(result, cb.buffer[:cb.count])
return result
}
// If buffer is full, we need to handle the wrap-around
// First copy from next to end
firstPartLength := cb.size - cb.next
if firstPartLength > 0 {
copy(result, cb.buffer[cb.next:])
}
// Then copy from start to next
if cb.next > 0 {
copy(result[firstPartLength:], cb.buffer[:cb.next])
}
return result
}
// Broker distributes messages from a source channel to multiple subscriber channels
type Broker struct {
sourceChan <-chan *meshtreampb.Packet // Source of packets (e.g., from MQTT client)
subscribers map[chan *meshtreampb.Packet]struct{} // Active subscribers
subscriberMutex sync.RWMutex // Lock for modifying the subscribers map
done chan struct{} // Signal to stop the dispatch loop
wg sync.WaitGroup // Wait group to ensure clean shutdown
logger logging.Logger // Logger for broker operations
subscriberMutex sync.RWMutex // Lock for modifying the subscribers map
done chan struct{} // Signal to stop the dispatch loop
wg sync.WaitGroup // Wait group to ensure clean shutdown
logger logging.Logger // Logger for broker operations
cache *CircularBuffer // Circular buffer for caching packets
}
// NewBroker creates a new broker that distributes messages from sourceChannel to subscribers
func NewBroker(sourceChannel <-chan *meshtreampb.Packet, logger logging.Logger) *Broker {
func NewBroker(sourceChannel <-chan *meshtreampb.Packet, cacheSize int, logger logging.Logger) *Broker {
broker := &Broker{
sourceChan: sourceChannel,
subscribers: make(map[chan *meshtreampb.Packet]struct{}),
done: make(chan struct{}),
logger: logger.Named("mqtt.broker"),
cache: NewCircularBuffer(cacheSize),
}
// Start the dispatch loop
@@ -44,6 +111,23 @@ func (b *Broker) Subscribe(bufferSize int) <-chan *meshtreampb.Packet {
b.subscribers[subscriberChan] = struct{}{}
b.subscriberMutex.Unlock()
// Send cached packets to the new subscriber
cachedPackets := b.cache.GetAll()
if len(cachedPackets) > 0 {
go func() {
for _, packet := range cachedPackets {
select {
case subscriberChan <- packet:
// Successfully sent packet
default:
// Buffer is full, log warning and stop sending cached packets
b.logger.Warn("New subscriber buffer full, stopping cache replay")
return
}
}
}()
}
// Return the channel
return subscriberChan
}
@@ -102,6 +186,9 @@ func (b *Broker) dispatchLoop() {
return
}
// Add packet to the cache
b.cache.Add(packet)
// Distribute the packet to all subscribers
b.broadcast(packet)
}
@@ -139,4 +226,4 @@ func (b *Broker) broadcast(packet *meshtreampb.Packet) {
}
}(ch)
}
}
}
+239 -4
View File
@@ -10,6 +10,64 @@ import (
meshtreampb "meshstream/generated/meshstream"
)
// TestCircularBuffer tests the circular buffer implementation
func TestCircularBuffer(t *testing.T) {
// Create a circular buffer with size 3
buffer := NewCircularBuffer(3)
// Test empty buffer returns empty slice
packets := buffer.GetAll()
if len(packets) != 0 {
t.Errorf("Expected empty buffer to return empty slice, got %d items", len(packets))
}
// Add 3 packets and verify count
for i := 1; i <= 3; i++ {
packet := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: uint32(i)},
Info: &meshtreampb.TopicInfo{},
}
buffer.Add(packet)
}
// Check that buffer has 3 packets
packets = buffer.GetAll()
if len(packets) != 3 {
t.Errorf("Expected buffer to have 3 packets, got %d", len(packets))
}
// Verify packets are in order
for i, packet := range packets {
expected := uint32(i + 1)
if packet.Data.Id != expected {
t.Errorf("Expected packet %d to have ID %d, got %d", i, expected, packet.Data.Id)
}
}
// Add 2 more packets to test wrap-around
for i := 4; i <= 5; i++ {
packet := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: uint32(i)},
Info: &meshtreampb.TopicInfo{},
}
buffer.Add(packet)
}
// Check that buffer still has 3 packets (maxed out)
packets = buffer.GetAll()
if len(packets) != 3 {
t.Errorf("Expected buffer to have 3 packets after overflow, got %d", len(packets))
}
// Verify packets are the latest ones in order (3, 4, 5)
for i, packet := range packets {
expected := uint32(i + 3)
if packet.Data.Id != expected {
t.Errorf("Expected packet %d to have ID %d, got %d", i, expected, packet.Data.Id)
}
}
}
// TestBrokerSubscribeUnsubscribe tests the basic subscribe and unsubscribe functionality
func TestBrokerSubscribeUnsubscribe(t *testing.T) {
// Create a test source channel
@@ -17,7 +75,7 @@ func TestBrokerSubscribeUnsubscribe(t *testing.T) {
// Create a broker with the source channel
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, testLogger)
broker := NewBroker(sourceChan, 5, testLogger)
defer broker.Close()
// Subscribe to the broker
@@ -103,7 +161,7 @@ func TestBrokerMultipleSubscribers(t *testing.T) {
// Create a broker with the source channel
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, testLogger)
broker := NewBroker(sourceChan, 5, testLogger)
defer broker.Close()
// Create multiple subscribers
@@ -149,7 +207,7 @@ func TestBrokerSlowSubscriber(t *testing.T) {
// Create a broker with the source channel
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, testLogger)
broker := NewBroker(sourceChan, 5, testLogger)
defer broker.Close()
// Create a slow subscriber with buffer size 1
@@ -221,7 +279,7 @@ func TestBrokerCloseWithSubscribers(t *testing.T) {
// Create a broker with the source channel
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, testLogger)
broker := NewBroker(sourceChan, 5, testLogger)
// Subscribe to the broker
subscriber := broker.Subscribe(5)
@@ -248,4 +306,181 @@ func TestBrokerCloseWithSubscribers(t *testing.T) {
case <-time.After(100 * time.Millisecond):
t.Error("Subscriber channel should be closed but isn't")
}
}
// TestBrokerPacketCaching tests that the broker caches packets
func TestBrokerPacketCaching(t *testing.T) {
// Create a test source channel
sourceChan := make(chan *meshtreampb.Packet, 10)
// Create a broker with a small cache size
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, 3, testLogger)
defer broker.Close()
// Send three packets
for i := 1; i <= 3; i++ {
packet := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: uint32(i)},
Info: &meshtreampb.TopicInfo{},
}
sourceChan <- packet
// Give the broker time to process the packet
time.Sleep(10 * time.Millisecond)
}
// Create a subscriber after the packets were sent
subscriber := broker.Subscribe(5)
// The subscriber should receive all three cached packets
receivedIds := make([]uint32, 0, 3)
// We need to receive 3 packets
for i := 0; i < 3; i++ {
select {
case received := <-subscriber:
receivedIds = append(receivedIds, received.Data.Id)
case <-time.After(100 * time.Millisecond):
t.Errorf("Subscriber didn't receive cached packet %d within timeout", i+1)
}
}
// Check that we received all packets in the correct order
if len(receivedIds) != 3 {
t.Errorf("Expected to receive 3 packets, got %d", len(receivedIds))
} else {
for i, id := range receivedIds {
if id != uint32(i+1) {
t.Errorf("Expected packet %d to have ID %d, got %d", i, i+1, id)
}
}
}
}
// TestBrokerCacheOverflow tests that the broker correctly handles cache overflow
func TestBrokerCacheOverflow(t *testing.T) {
// Create a test source channel
sourceChan := make(chan *meshtreampb.Packet, 10)
// Create a broker with a small cache size
testLogger := logging.NewDevLogger().Named("test")
cacheSize := 3
broker := NewBroker(sourceChan, cacheSize, testLogger)
defer broker.Close()
// Send 5 packets (exceeding the cache size)
for i := 1; i <= 5; i++ {
packet := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: uint32(i)},
Info: &meshtreampb.TopicInfo{},
}
sourceChan <- packet
// Give the broker time to process the packet
time.Sleep(10 * time.Millisecond)
}
// Create a subscriber after the packets were sent
subscriber := broker.Subscribe(5)
// The subscriber should receive the last 3 packets (3, 4, 5)
receivedIds := make([]uint32, 0, cacheSize)
// We expect to receive exactly cacheSize packets
for i := 0; i < cacheSize; i++ {
select {
case received := <-subscriber:
receivedIds = append(receivedIds, received.Data.Id)
case <-time.After(100 * time.Millisecond):
t.Errorf("Subscriber didn't receive cached packet %d within timeout", i+1)
}
}
// Verify no more packets are coming
select {
case received := <-subscriber:
t.Errorf("Received unexpected packet with ID %d", received.Data.Id)
case <-time.After(50 * time.Millisecond):
// This is expected, no more packets should be received
}
// Check that we received only the last 3 packets in the correct order
expectedIds := []uint32{3, 4, 5}
if len(receivedIds) != len(expectedIds) {
t.Errorf("Expected to receive %d packets, got %d", len(expectedIds), len(receivedIds))
} else {
for i, id := range receivedIds {
if id != expectedIds[i] {
t.Errorf("Expected packet %d to have ID %d, got %d", i, expectedIds[i], id)
}
}
}
}
// TestSubscriberBufferFull tests the behavior when a subscriber's buffer is full
func TestSubscriberBufferFull(t *testing.T) {
// Create a test source channel
sourceChan := make(chan *meshtreampb.Packet, 10)
// Create a broker with a cache size of 5
testLogger := logging.NewDevLogger().Named("test")
broker := NewBroker(sourceChan, 5, testLogger)
defer broker.Close()
// Prefill the cache with 5 packets
for i := 1; i <= 5; i++ {
packet := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: uint32(i)},
Info: &meshtreampb.TopicInfo{},
}
sourceChan <- packet
time.Sleep(10 * time.Millisecond)
}
// Create a subscriber with a very small buffer size (1)
smallSubscriber := broker.Subscribe(1)
// The small subscriber should receive at least one cached packet
// The others will be dropped because the buffer is full
select {
case received := <-smallSubscriber:
if received.Data.Id != 1 {
t.Errorf("Expected subscriber to receive packet with ID 1, got %d", received.Data.Id)
}
case <-time.After(100 * time.Millisecond):
t.Error("Subscriber didn't receive any cached packet within timeout")
}
// Check that no more packets are immediately available
// This is a bit tricky to test since we can't guarantee how many
// packets were dropped due to the full buffer, but we can check
// that the channel isn't immediately ready with more packets
select {
case received := <-smallSubscriber:
// If we get here, it should be a later packet from the cache
if received.Data.Id < 1 {
t.Errorf("Received unexpected packet with ID %d", received.Data.Id)
}
case <-time.After(50 * time.Millisecond):
// This is also acceptable - it means all attempts to send more cached
// packets found the buffer full and gave up
}
// Send a new packet now that the subscriber is connected
newPacket := &meshtreampb.Packet{
Data: &meshtreampb.Data{Id: 6},
Info: &meshtreampb.TopicInfo{},
}
sourceChan <- newPacket
// The subscriber should receive this packet if they read the first one
select {
case received := <-smallSubscriber:
if received.Data.Id != 6 {
t.Errorf("Expected subscriber to receive packet with ID 6, got %d", received.Data.Id)
}
case <-time.After(100 * time.Millisecond):
t.Error("Subscriber didn't receive new packet within timeout")
}
}
+2 -2
View File
@@ -133,8 +133,8 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
// Subscribe to the broker with a buffer size of 10
packetChan := s.config.Broker.Subscribe(10)
// Subscribe to the broker with a buffer size of 100
packetChan := s.config.Broker.Subscribe(100)
// Signal when the client disconnects
notify := ctx.Done()
+4 -2
View File
@@ -28,7 +28,7 @@ interface NavProps {
// Navigation items data
const navigationItems: NavItem[] = [
{
to: "/",
to: "/home",
label: "Dashboard",
icon: LayoutDashboard,
exact: true,
@@ -77,9 +77,11 @@ export const Nav: React.FC<NavProps> = ({ connectionStatus }) => {
"text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: item.exact,
className: "text-neutral-200 font-normal",
}}
activeOptions={{
exact: item.exact,
}}
>
<item.icon className="h-4 w-4 mr-3" />
{item.label}
+52 -41
View File
@@ -103,13 +103,9 @@ export const PacketList: React.FC = () => {
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full max-w-4xl ">
<div className="sticky top-0 z-10">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-neutral-400 px-2">
{packets.length} packets received
{packets.length > 0 && <>, since {getEarliestTime()}</>}
</div>
<div className="flex items-center space-x-3">
{/* Show buffered count when paused */}
{streamPaused && bufferedPackets.length > 0 && (
@@ -134,6 +130,10 @@ export const PacketList: React.FC = () => {
<span className="text-sm font-medium">Clear</span>
</button>
</div>
<div className="text-sm text-neutral-400 px-2">
{packets.length} packets received
{packets.length > 0 && <>, since {getEarliestTime()}</>}
</div>
</div>
<Separator className="mx-0" />
</div>
@@ -175,11 +175,17 @@ export const PacketList: React.FC = () => {
<div className="mt-auto">
{/* Empty state when no packets are visible but stream is paused */}
{packets.length === 0 && streamPaused && bufferedPackets.length > 0 && (
<div className="p-6 text-amber-400 text-center border border-amber-900 bg-neutral-800 rounded mb-4">
Stream is paused with {bufferedPackets.length} buffered messages.
<div className="p-4 text-amber-400 text-center border border-amber-900/60 bg-neutral-800/50 rounded-lg effect-inset mb-4">
<div className="flex items-center justify-center mb-2">
<Archive className="w-4 h-4 mr-2" />
<span>
Stream is paused with {bufferedPackets.length} buffered
messages.
</span>
</div>
<button
onClick={handleToggleStream}
className="block mx-auto mt-2 px-3 py-1.5 text-sm bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
className="inline-flex items-center px-3 py-1.5 mt-2 text-sm effect-outset border border-neutral-950/90 rounded-md text-neutral-300 hover:bg-neutral-700/50 transition-colors"
>
Resume to view
</button>
@@ -188,41 +194,46 @@ export const PacketList: React.FC = () => {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center text-sm sticky bottom-0 bg-neutral-50/5 py-2">
<button
onClick={() =>
setCurrentPage(currentPage > 1 ? currentPage - 1 : 1)
}
disabled={currentPage === 1}
className={`px-3 py-1.5 rounded ${
currentPage === 1
? "text-neutral-500 cursor-not-allowed"
: "bg-neutral-700 text-neutral-200 hover:bg-neutral-600"
}`}
>
Previous
</button>
<>
<Separator className="mx-0 mt-4" />
<div className="flex justify-between items-center text-sm py-2 bg-neutral-800/50 sticky bottom-0">
<div className="text-sm text-neutral-400 px-2">
Page {currentPage} of {totalPages}
</div>
<div className="text-neutral-400">
Page {currentPage} of {totalPages}
<div className="flex items-center space-x-3">
<button
onClick={() =>
setCurrentPage(currentPage > 1 ? currentPage - 1 : 1)
}
disabled={currentPage === 1}
className={`flex items-center px-3 py-1.5 effect-outset border border-neutral-950/90 rounded-md ${
currentPage === 1
? "text-neutral-500 cursor-not-allowed opacity-50"
: "text-neutral-400 hover:bg-neutral-700/50"
}`}
>
<span className="text-sm font-medium">Previous</span>
</button>
<button
onClick={() =>
setCurrentPage(
currentPage < totalPages ? currentPage + 1 : totalPages
)
}
disabled={currentPage === totalPages}
className={`flex items-center px-3 py-1.5 effect-outset border border-neutral-950/90 rounded-md ${
currentPage === totalPages
? "text-neutral-500 cursor-not-allowed opacity-50"
: "text-neutral-400 hover:bg-neutral-700/50"
}`}
>
<span className="text-sm font-medium">Next</span>
</button>
</div>
</div>
<button
onClick={() =>
setCurrentPage(
currentPage < totalPages ? currentPage + 1 : totalPages
)
}
disabled={currentPage === totalPages}
className={`px-3 py-1.5 rounded ${
currentPage === totalPages
? "text-neutral-500 cursor-not-allowed"
: "bg-neutral-700 text-neutral-200 hover:bg-neutral-600"
}`}
>
Next
</button>
</div>
</>
)}
</div>
</div>
+351 -282
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef, useCallback } from "react";
import { useNavigate, Link } from "@tanstack/react-router";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { selectNode } from "../../store/slices/aggregatorSlice";
import { RegionCode, ModemPreset } from "../../lib/types";
import { RegionCode, ModemPreset, Packet } from "../../lib/types";
import { KeyValuePair } from "../ui/KeyValuePair";
import { Separator } from "../Separator";
import { PacketRenderer } from "../packets/PacketRenderer";
import {
ArrowLeft,
Radio,
@@ -29,6 +30,7 @@ import {
Earth,
TableConfig,
Save,
MessageSquare,
} from "lucide-react";
interface NodeDetailProps {
@@ -357,6 +359,63 @@ const getModemPresetName = (
return presetNames[preset] || `Unknown (${preset})`;
};
// Component to render packets associated with a specific node
const NodePacketList: React.FC<{ nodeId: number }> = ({ nodeId }) => {
const { packets } = useAppSelector((state) => state.packets);
// Fixed number of packets to display
const MAX_PACKETS = 20;
// Get packets from this node (sent or received)
const nodePackets = packets
.filter(
(packet) => packet.data.from === nodeId || packet.data.to === nodeId
)
.slice(0, MAX_PACKETS); // Show fixed number of packets
// Generate a reproducible packet key
const getPacketKey = useCallback((packet: Packet, index: number): string => {
if (packet.data.id !== undefined && packet.data.from !== undefined) {
const fromId = `!${packet.data.from.toString(16).toLowerCase()}`;
return `${fromId}_${packet.data.id}`;
}
return `fallback_${index}`;
}, []);
if (nodePackets.length === 0) {
return (
<div className="p-6 effect-inset rounded-lg border border-neutral-950/60 bg-neutral-800/50 text-neutral-400 text-center">
No packets found for this node
</div>
);
}
return (
<div className="flex flex-col h-full w-full">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm text-neutral-400 px-2">
Showing {nodePackets.length} of{" "}
{
packets.filter(
(p) => p.data.from === nodeId || p.data.to === nodeId
).length
}{" "}
recent packets
</h3>
</div>
<Separator className="mx-0 mb-4" />
<ul className="space-y-8 w-full">
{nodePackets.map((packet, index) => (
<li key={getPacketKey(packet, index)}>
<PacketRenderer packet={packet} />
</li>
))}
</ul>
</div>
);
};
export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -481,7 +540,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
const positionAccuracy = calculateAccuracyFromPrecisionBits(precisionBits);
return (
<div>
<div className="max-w-4xl">
{/* Header with back button and basic node info */}
<div className="flex items-center p-4 bg-neutral-800/50 rounded-lg">
<button
@@ -518,281 +577,123 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
<Separator />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="lg:col-span-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Basic Info */}
<Section
title="Device Information"
icon={<Cpu className="w-4 h-4" />}
>
{node.longName && (
<KeyValuePair label="Name" value={node.longName} inset={true} />
)}
{/* First column: Device Information and Device Status */}
<div>
{/* Basic Info */}
<Section
title="Device Information"
icon={<Cpu className="w-4 h-4" />}
>
{node.longName && (
<KeyValuePair label="Name" value={node.longName} inset={true} />
)}
{node.hwModel && (
<KeyValuePair
label="Hardware"
value={node.hwModel}
icon={<Cpu className="w-3 h-3" />}
inset={true}
/>
)}
{node.hwModel && (
<KeyValuePair
label="Hardware"
value={node.hwModel}
icon={<Cpu className="w-3 h-3" />}
inset={true}
/>
)}
{node.macAddr && (
<KeyValuePair
label="MAC Address"
value={node.macAddr}
monospace={true}
inset={true}
/>
)}
{node.macAddr && (
<KeyValuePair
label="MAC Address"
value={node.macAddr}
monospace={true}
inset={true}
/>
)}
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Wifi className="w-3 h-3 mr-2 text-neutral-300" />
Channels
</span>
<div className="flex flex-col items-end">
{node.channelId ? (
<Link
to="/channel/$channelId"
params={{ channelId: node.channelId }}
className="text-neutral-200 flex items-center hover:text-blue-400 transition-colors"
>
<Wifi className="w-3 h-3 mr-1.5 text-blue-400" />
<span className="font-mono text-sm">
{node.channelId}
</span>
</Link>
) : (
<span className="text-neutral-400 italic">
None detected
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Wifi className="w-3 h-3 mr-2 text-neutral-300" />
Channels
</span>
<div className="flex flex-col items-end">
{node.channelId ? (
<Link
to="/channel/$channelId"
params={{ channelId: node.channelId }}
className="text-neutral-200 flex items-center hover:text-blue-400 transition-colors"
>
<Wifi className="w-3 h-3 mr-1.5 text-blue-400" />
<span className="font-mono text-sm">
{node.channelId}
</span>
)}
{node.mapReport?.hasDefaultChannel !== undefined && (
<span className="text-xs text-neutral-400">
Default channel:{" "}
{node.mapReport.hasDefaultChannel ? "Yes" : "No"}
</span>
)}
</div>
</Link>
) : (
<span className="text-neutral-400 italic">
None detected
</span>
)}
{node.mapReport?.hasDefaultChannel !== undefined && (
<span className="text-xs text-neutral-400">
Default channel:{" "}
{node.mapReport.hasDefaultChannel ? "Yes" : "No"}
</span>
)}
</div>
</div>
{/* 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">
{/* 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">
<Signal className="w-4 h-4 mr-1.5" />
Gateway Node
</span>
{node.observedNodeCount !== undefined && (
<span className="text-blue-400 flex items-center">
<Signal className="w-4 h-4 mr-1.5" />
Gateway Node
<Users className="w-4 h-4 mr-1.5" />
{node.observedNodeCount}{" "}
{node.observedNodeCount === 1 ? "node" : "nodes"}
</span>
{node.observedNodeCount !== undefined && (
<span className="text-blue-400 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">
{node.mapReport.numOnlineLocalNodes} online local nodes
</span>
)}
</div>
{node.mapReport?.region !== undefined && (
<KeyValuePair
label="Region"
value={getRegionName(node.mapReport.region)}
icon={<Earth className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.modemPreset !== undefined && (
<KeyValuePair
label="Modem Preset"
value={getModemPresetName(node.mapReport.modemPreset)}
icon={<TableConfig className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.firmwareVersion && (
<KeyValuePair
label="Firmware"
value={node.mapReport.firmwareVersion}
monospace={true}
icon={<Save className="w-3 h-3" />}
inset={true}
/>
)}
</div>
)}
</Section>
{/* Activity */}
<Section title="Last Activity" icon={<Clock className="w-4 h-4" />}>
<KeyValuePair
label="Date"
value={lastHeardDay}
icon={<Calendar className="w-3 h-3" />}
monospace={true}
inset={true}
/>
<KeyValuePair
label="Time"
value={lastHeardTime}
icon={<Clock className="w-3 h-3" />}
monospace={true}
inset={true}
/>
{node.deviceMetrics?.uptimeSeconds !== undefined && (
<KeyValuePair
label="Uptime"
value={formatUptime(node.deviceMetrics.uptimeSeconds)}
icon={<Timer className="w-3 h-3" />}
monospace={true}
highlight={node.deviceMetrics.uptimeSeconds > 86400}
inset={true}
/>
)}
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Signal className="w-3 h-3 mr-2 text-neutral-300" />
Gateways
</span>
<div className="flex flex-col items-end">
{node.gatewayId ? (
// Check if gateway ID matches the current node ID (self-reporting)
node.gatewayId ===
`!${nodeId.toString(16).toLowerCase()}` ? (
<span className="text-emerald-400 text-xs flex items-center font-mono">
Self reported
</span>
) : (
<Link
to="/node/$nodeId"
params={{ nodeId: node.gatewayId.substring(1) }}
className="font-mono text-xs truncate max-w-[180px] text-blue-400 hover:text-blue-300 transition-colors flex items-center"
>
{node.gatewayId}
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
)
) : (
<span className="text-neutral-400 italic">
None detected
{node.mapReport?.numOnlineLocalNodes !== undefined && (
<span className="text-emerald-400 text-xs flex items-center font-mono">
{node.mapReport.numOnlineLocalNodes} online local nodes
</span>
)}
</div>
</div>
<div className="flex items-center justify-between p-2 rounded bg-neutral-700/50 effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Radio className="w-3 h-3 mr-2 text-neutral-300" />
Packets
</span>
<div className="flex space-x-3">
<div className="flex flex-col items-center">
<span className="text-amber-500 font-mono text-lg">
{node.messageCount}
</span>
<span className="text-xs text-neutral-500">Total</span>
</div>
<div className="flex flex-col items-center">
<span className="text-green-500 font-mono text-lg">
{node.textMessageCount}
</span>
<span className="text-xs text-neutral-500">Text</span>
</div>
</div>
</div>
</Section>
</div>
{/* Position Map */}
{hasPosition && (
<Section
title="Node Location"
icon={<Map className="w-4 h-4" />}
className="mt-4"
>
<div className="h-[350px] rounded-lg overflow-hidden relative shadow-inner">
<GoogleMap
lat={latitude}
lng={longitude}
precisionBits={precisionBits}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3 text-sm">
<KeyValuePair
label="Coordinates"
value={`${latitude.toFixed(6)}, ${longitude.toFixed(6)}`}
monospace={true}
inset={true}
/>
{node.position?.altitude !== undefined && (
{node.mapReport?.region !== undefined && (
<KeyValuePair
label="Altitude"
value={`${node.position.altitude} m`}
monospace={true}
label="Region"
value={getRegionName(node.mapReport.region)}
icon={<Earth className="w-3 h-3" />}
inset={true}
/>
)}
{/* Position Accuracy */}
<KeyValuePair
label="Accuracy"
value={
positionAccuracy < 1000
? `±${positionAccuracy.toFixed(0)} m`
: `±${(positionAccuracy / 1000).toFixed(1)} km`
}
monospace={true}
inset={true}
/>
{precisionBits !== undefined && (
{node.mapReport?.modemPreset !== undefined && (
<KeyValuePair
label="Precision"
value={`${precisionBits} bits`}
monospace={true}
label="Modem Preset"
value={getModemPresetName(node.mapReport.modemPreset)}
icon={<TableConfig className="w-3 h-3" />}
inset={true}
/>
)}
{node.position?.satsInView !== undefined && (
{node.mapReport?.firmwareVersion && (
<KeyValuePair
label="Satellites"
value={node.position.satsInView}
monospace={true}
highlight={node.position.satsInView > 6}
inset={true}
/>
)}
{node.position?.groundSpeed !== undefined && (
<KeyValuePair
label="Speed"
value={`${node.position.groundSpeed} m/s`}
label="Firmware"
value={node.mapReport.firmwareVersion}
monospace={true}
icon={<Save className="w-3 h-3" />}
inset={true}
/>
)}
</div>
</Section>
)}
</div>
)}
</Section>
<div className="space-y-6">
{/* Telemetry Info - Device Metrics */}
{/* Device Status - Moved from the right column to here */}
{(node.deviceMetrics ||
node.batteryLevel !== undefined ||
node.snr !== undefined) && (
<Section title="Device Status" icon={<Cpu className="w-4 h-4" />}>
<Section title="Device Status" icon={<Cpu className="w-4 h-4" />} className="mt-4">
<div className="space-y-4">
{node.batteryLevel !== undefined && (
<BatteryLevel level={node.batteryLevel} />
@@ -862,12 +763,117 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Section>
)}
{/* Warning for low battery */}
{node.batteryLevel !== undefined && node.batteryLevel < 20 && (
<div className="bg-red-900/30 border border-red-800 p-4 rounded-lg effect-inset mt-4">
<div className="flex items-center text-red-400">
<AlertTriangle className="w-4 h-4 mr-2" />
<h3 className="font-medium font-mono tracking-wider">
LOW BATTERY WARNING
</h3>
</div>
<p className="text-sm mt-2 text-neutral-300 flex items-center">
<BatteryLow className="w-3 h-3 mr-1.5 text-red-400" />
Battery level critically low at{" "}
<span className="font-mono text-red-400 mx-1">
{node.batteryLevel}%
</span>
Device may stop reporting soon.
</p>
</div>
)}
</div>
{/* Second column: Last Activity and Environment Metrics */}
<div>
{/* Activity */}
<Section title="Last Activity" icon={<Clock className="w-4 h-4" />}>
<KeyValuePair
label="Date"
value={lastHeardDay}
icon={<Calendar className="w-3 h-3" />}
monospace={true}
inset={true}
/>
<KeyValuePair
label="Time"
value={lastHeardTime}
icon={<Clock className="w-3 h-3" />}
monospace={true}
inset={true}
/>
{node.deviceMetrics?.uptimeSeconds !== undefined && (
<KeyValuePair
label="Uptime"
value={formatUptime(node.deviceMetrics.uptimeSeconds)}
icon={<Timer className="w-3 h-3" />}
monospace={true}
highlight={node.deviceMetrics.uptimeSeconds > 86400}
inset={true}
/>
)}
<div className="flex justify-between items-center bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Signal className="w-3 h-3 mr-2 text-neutral-300" />
Gateways
</span>
<div className="flex flex-col items-end">
{node.gatewayId ? (
// Check if gateway ID matches the current node ID (self-reporting)
node.gatewayId ===
`!${nodeId.toString(16).toLowerCase()}` ? (
<span className="text-emerald-400 text-xs flex items-center font-mono">
Self reported
</span>
) : (
<Link
to="/node/$nodeId"
params={{ nodeId: node.gatewayId.substring(1) }}
className="font-mono text-xs truncate max-w-[180px] text-blue-400 hover:text-blue-300 transition-colors flex items-center"
>
{node.gatewayId}
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
)
) : (
<span className="text-neutral-400 italic">
None detected
</span>
)}
</div>
</div>
<div className="flex items-center justify-between p-2 rounded bg-neutral-700/50 effect-inset">
<span className="text-neutral-400 flex items-center text-sm">
<Radio className="w-3 h-3 mr-2 text-neutral-300" />
Packets
</span>
<div className="flex space-x-3">
<div className="flex flex-col items-center">
<span className="text-amber-500 font-mono text-lg">
{node.messageCount}
</span>
<span className="text-xs text-neutral-500">Total</span>
</div>
<div className="flex flex-col items-center">
<span className="text-green-500 font-mono text-lg">
{node.textMessageCount}
</span>
<span className="text-xs text-neutral-500">Text</span>
</div>
</div>
</div>
</Section>
{/* Telemetry Info - Environment Metrics */}
{node.environmentMetrics &&
Object.keys(node.environmentMetrics).length > 0 && (
<Section
title="Environment Data"
icon={<Thermometer className="w-4 h-4" />}
className="mt-4"
>
<div className="space-y-4">
{node.environmentMetrics.temperature !== undefined && (
@@ -879,14 +885,14 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</span>
<span
className={`
${
node.environmentMetrics.temperature > 30
? "text-red-500"
: node.environmentMetrics.temperature < 10
? "text-blue-500"
: "text-green-500"
} font-mono text-sm
`}
${
node.environmentMetrics.temperature > 30
? "text-red-500"
: node.environmentMetrics.temperature < 10
? "text-blue-500"
: "text-green-500"
} font-mono text-sm
`}
>
{node.environmentMetrics.temperature}°C
</span>
@@ -895,14 +901,14 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
{/* Temp scale: -10°C to 40°C mapped to 0-100% */}
<div
className={`
${
node.environmentMetrics.temperature > 30
? "bg-red-500"
: node.environmentMetrics.temperature < 10
? "bg-blue-500"
: "bg-green-500"
} h-2 rounded-full
`}
${
node.environmentMetrics.temperature > 30
? "bg-red-500"
: node.environmentMetrics.temperature < 10
? "bg-blue-500"
: "bg-green-500"
} h-2 rounded-full
`}
style={{
width: `${Math.max(0, Math.min(100, ((node.environmentMetrics.temperature + 10) / 50) * 100))}%`,
}}
@@ -967,28 +973,91 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</div>
</Section>
)}
{/* Warning for low battery */}
{node.batteryLevel !== undefined && node.batteryLevel < 20 && (
<div className="bg-red-900/30 border border-red-800 p-4 rounded-lg effect-inset">
<div className="flex items-center text-red-400">
<AlertTriangle className="w-4 h-4 mr-2" />
<h3 className="font-medium font-mono tracking-wider">
LOW BATTERY WARNING
</h3>
</div>
<p className="text-sm mt-2 text-neutral-300 flex items-center">
<BatteryLow className="w-3 h-3 mr-1.5 text-red-400" />
Battery level critically low at{" "}
<span className="font-mono text-red-400 mx-1">
{node.batteryLevel}%
</span>
Device may stop reporting soon.
</p>
</div>
)}
</div>
</div>
{/* Position Map - Full Width */}
{hasPosition && (
<Section
title="Node Location"
icon={<Map className="w-4 h-4" />}
className="mt-6"
>
<div className="h-[400px] rounded-lg overflow-hidden relative shadow-inner">
<GoogleMap
lat={latitude}
lng={longitude}
precisionBits={precisionBits}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
<KeyValuePair
label="Coordinates"
value={`${latitude.toFixed(6)}, ${longitude.toFixed(6)}`}
monospace={true}
inset={true}
/>
{node.position?.altitude !== undefined && (
<KeyValuePair
label="Altitude"
value={`${node.position.altitude} m`}
monospace={true}
inset={true}
/>
)}
{/* Position Accuracy */}
<KeyValuePair
label="Accuracy"
value={
positionAccuracy < 1000
? `±${positionAccuracy.toFixed(0)} m`
: `±${(positionAccuracy / 1000).toFixed(1)} km`
}
monospace={true}
inset={true}
/>
{precisionBits !== undefined && (
<KeyValuePair
label="Precision"
value={`${precisionBits} bits`}
monospace={true}
inset={true}
/>
)}
{node.position?.satsInView !== undefined && (
<KeyValuePair
label="Satellites"
value={node.position.satsInView}
monospace={true}
highlight={node.position.satsInView > 6}
inset={true}
/>
)}
{node.position?.groundSpeed !== undefined && (
<KeyValuePair
label="Speed"
value={`${node.position.groundSpeed} m/s`}
monospace={true}
inset={true}
/>
)}
</div>
</Section>
)}
{/* Recent Packets - Full Width */}
<Section
title="Recent Packets"
icon={<MessageSquare className="w-4 h-4" />}
className="mt-6"
>
<NodePacketList nodeId={nodeId} />
</Section>
</div>
);
};
+7 -3
View File
@@ -41,8 +41,12 @@ export const PacketCard: React.FC<PacketCardProps> = ({
? `!${data.from.toString(16).toLowerCase()}`
: "Unknown"}
</span>
<span className="text-neutral-500">on</span>
<span className="text-neutral-400">{packet.info.channel}</span>
{packet.info.channel && (
<>
<span className="text-neutral-500">on</span>
<span className="text-neutral-400">{packet.info.channel}</span>
</>
)}
{data.gatewayId && (
<>
<span className="text-neutral-500">via</span>
@@ -54,7 +58,7 @@ export const PacketCard: React.FC<PacketCardProps> = ({
{/* Right side: ID, Time, and Type */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center">
<span className="text-neutral-400">{data.id || "None"}</span>
<span className="text-neutral-400">{data.id || ""}</span>
{data.rxTime && (
<span className="text-neutral-500 ml-2">
{new Date(data.rxTime * 1000).toLocaleTimeString([], {