mirror of
https://github.com/dpup/meshstream.git
synced 2026-08-07 01:03:10 +02:00
Cache recent packets and send to client when it connects
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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([], {
|
||||
|
||||
Reference in New Issue
Block a user