Tweaks to channel list and messages

This commit is contained in:
Daniel Pupius
2025-04-26 17:24:17 -07:00
parent c7abbbd4be
commit 4fd898222b
7 changed files with 214 additions and 171 deletions

View File

@@ -0,0 +1,144 @@
import React from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAppSelector } from "../../hooks";
import { Separator } from "../Separator";
import { MessageBubble } from "../messages";
import { Section } from "../ui/Section";
import { ArrowLeft, MessageSquare, Users, Wifi } from "lucide-react";
interface ChannelDetailProps {
channelId: string;
}
// Generate a deterministic color based on channel ID
const getChannelColor = (channelId: string) => {
const colors = [
"bg-green-500",
"bg-blue-500",
"bg-amber-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-red-500",
"bg-teal-500",
];
// Use a hash of the channel ID to pick a consistent color
const hash = channelId
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
export const ChannelDetail: React.FC<ChannelDetailProps> = ({ channelId }) => {
const navigate = useNavigate();
const { channels, messages, nodes } = useAppSelector(
(state) => state.aggregator
);
const channel = channels[channelId];
// Create the channel key in the same format used by the aggregator
const channelKey = `channel_${channelId}`;
// Get channel messages and sort by timestamp (oldest to newest)
// This creates a shallow copy of the message array to sort it without modifying the original
const channelMessages = messages[channelKey]
? [...messages[channelKey]].sort((a, b) => a.timestamp - b.timestamp)
: [];
const handleBack = () => {
navigate({ to: "/channels" });
};
if (!channel) {
return (
<div className="p-6 text-red-400 border border-red-900 rounded bg-neutral-900 effect-inset">
<div className="flex items-center mb-3">
<button
onClick={handleBack}
className="flex items-center mr-3 px-2 py-1 text-sm bg-neutral-800 hover:bg-neutral-700 rounded transition-colors effect-inset"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Back
</button>
<h1 className="text-xl font-semibold">Channel Not Found</h1>
</div>
<p>The channel {channelId} was not found or has not been seen yet.</p>
</div>
);
}
// Get the channel color based on its ID
const channelColor = getChannelColor(channelId);
// Determine if the channel is active (has messages in the last 10 minutes)
const isActive = channelMessages.length > 0 &&
(Math.floor(Date.now() / 1000) - channelMessages[channelMessages.length - 1].timestamp) < 600;
return (
<div className="max-w-4xl h-full flex flex-col">
{/* Header with back button and channel info */}
<div className="flex items-center px-4 bg-neutral-800/50 rounded-lg">
<button
onClick={handleBack}
className="flex items-center mr-4 p-2 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700 rounded-full transition-colors effect-outset"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div
className={`p-2 mr-3 rounded-full ${channelColor} flex items-center justify-center effect-inset`}
>
<Wifi className="w-4 h-4 text-white" />
</div>
<div className="flex-1 flex flex-col md:flex-row md:items-center">
<h1 className="text-xl font-semibold text-neutral-200 mr-3">
{channelId}
</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"}`}
></span>
{isActive ? "Active" : "Inactive"}
</div>
</div>
<div className="flex space-x-4 items-center">
<div className="flex items-center text-neutral-300">
<Users className="w-4 h-4 mr-1.5 text-blue-400" />
<span className="text-sm">{channel.nodes.length}</span>
</div>
<div className="flex items-center text-neutral-300">
<MessageSquare className="w-4 h-4 mr-1.5 text-green-400" />
<span className="text-sm">{channel.textMessageCount}</span>
</div>
</div>
</div>
<Separator />
{/* Message List - Chat style without title */}
<div className="flex-1 overflow-y-auto pb-4 mt-4">
{channelMessages.length === 0 ? (
<div className="bg-neutral-800/50 rounded-lg p-6 text-center text-neutral-400 effect-inset">
<p>No messages in this channel yet.</p>
</div>
) : (
<div className="space-y-4 px-2">
{channelMessages.map((message) => {
const nodeName =
nodes[message.from]?.shortName ||
nodes[message.from]?.longName;
return (
<MessageBubble
key={`${message.from}-${message.id}`}
message={message}
nodeName={nodeName}
/>
);
})}
</div>
)}
</div>
</div>
);
};

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, Link } from "@tanstack/react-router";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { selectNode } from "../../store/slices/aggregatorSlice";
import {
import {
ArrowLeft,
Radio,
Cpu,
@@ -17,7 +17,8 @@ import {
Earth,
TableConfig,
Save,
MessageSquare
MessageSquare,
Thermometer,
} from "lucide-react";
import { Separator } from "../Separator";
import { KeyValuePair } from "../ui/KeyValuePair";
@@ -31,7 +32,11 @@ import { NodePacketList } from "./NodePacketList";
import { LowBatteryWarning } from "./LowBatteryWarning";
import { UtilizationMetrics } from "./UtilizationMetrics";
import { calculateAccuracyFromPrecisionBits } from "../../lib/mapUtils";
import { formatUptime, getRegionName, getModemPresetName } from "../../utils/formatters";
import {
formatUptime,
getRegionName,
getModemPresetName,
} from "../../utils/formatters";
interface NodeDetailProps {
nodeId: number;
@@ -159,7 +164,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
return (
<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">
<div className="flex items-center px-4 bg-neutral-800/50 rounded-lg">
<button
onClick={handleBack}
className="flex items-center mr-4 p-2 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700 rounded-full transition-colors effect-outset"
@@ -236,14 +241,10 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
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>
<span className="font-mono text-sm">{node.channelId}</span>
</Link>
) : (
<span className="text-neutral-400 italic">
None detected
</span>
<span className="text-neutral-400 italic">None detected</span>
)}
{node.mapReport?.hasDefaultChannel !== undefined && (
<span className="text-xs text-neutral-400">
@@ -310,7 +311,11 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
{(node.deviceMetrics ||
node.batteryLevel !== undefined ||
node.snr !== undefined) && (
<Section title="Device Status" icon={<Cpu className="w-4 h-4" />} className="mt-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} />
@@ -385,8 +390,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
<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()}` ? (
node.gatewayId === `!${nodeId.toString(16).toLowerCase()}` ? (
<span className="text-emerald-400 text-xs flex items-center font-mono">
Self reported
</span>
@@ -401,9 +405,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Link>
)
) : (
<span className="text-neutral-400 italic">
None detected
</span>
<span className="text-neutral-400 italic">None detected</span>
)}
</div>
</div>
@@ -440,7 +442,9 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
<EnvironmentMetrics
temperature={node.environmentMetrics.temperature}
relativeHumidity={node.environmentMetrics.relativeHumidity}
barometricPressure={node.environmentMetrics.barometricPressure}
barometricPressure={
node.environmentMetrics.barometricPressure
}
soilMoisture={node.environmentMetrics.soilMoisture}
/>
</Section>
@@ -484,4 +488,4 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Section>
</div>
);
};
};

View File

@@ -43,20 +43,6 @@ export const NodePacketList: React.FC<NodePacketListProps> = ({ nodeId }) => {
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)}>
@@ -66,4 +52,4 @@ export const NodePacketList: React.FC<NodePacketListProps> = ({ nodeId }) => {
</ul>
</div>
);
};
};

View File

@@ -28,10 +28,10 @@ export const NodePositionData: React.FC<NodePositionDataProps> = ({
positionAccuracy,
precisionBits,
satsInView,
groundSpeed
groundSpeed,
}) => {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
<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)}`}
@@ -88,4 +88,4 @@ export const NodePositionData: React.FC<NodePositionDataProps> = ({
)}
</div>
);
};
};

View File

@@ -2,6 +2,7 @@ export * from './NodeList';
export * from './GatewayList';
export * from './MeshCard';
export * from './NodeDetail';
export * from './ChannelDetail';
export * from './BatteryLevel';
export * from './SignalStrength';
export * from './GoogleMap';

View File

@@ -40,26 +40,42 @@ const getNodeInitials = (nodeId: number, nodeName?: string) => {
return nodeId.toString(16).slice(-4).toUpperCase();
};
// Get the preferred display name for a node
const getDisplayName = (nodeId: number, nodeName?: string) => {
if (!nodeName) {
return `Node ${nodeId.toString(16)}`;
}
// Prefer longName if available
return nodeName;
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, nodeName }) => {
const initials = getNodeInitials(message.from, nodeName);
const nodeColor = getNodeColor(message.from);
const displayName = getDisplayName(message.from, nodeName);
return (
<div className="flex">
<div className={`flex-shrink-0 w-9 h-9 rounded-full ${nodeColor} flex items-center justify-center text-white text-xs font-bold`}>
{initials}
<div>
{/* Header row with name and timestamp aligned right */}
<div className="flex justify-end items-baseline mb-1 px-2 space-x-2">
<span className="text-neutral-200 font-medium font-mono text-xs">
{displayName}
</span>
<span className="text-[10px] text-neutral-500">
{formatTimestamp(message.timestamp)}
</span>
</div>
<div className="ml-3 flex-1">
<div className="flex items-baseline">
<span className="text-neutral-200 font-medium">
{nodeName || `Node ${message.from.toString(16)}`}
</span>
<span className="ml-2 text-xs text-neutral-500">
{formatTimestamp(message.timestamp)}
</span>
{/* Message row with avatar and text */}
<div className="flex items-start">
<div className={`flex-shrink-0 w-8 h-8 rounded-full ${nodeColor} flex items-center justify-center text-white text-[9px] font-bold`}>
{initials}
</div>
<div className="mt-1 bg-neutral-800 rounded-lg py-2 px-3 text-neutral-300 break-words">
{message.text}
<div className="ml-3 flex-1">
<div className="bg-neutral-800 rounded-lg py-2 px-3 text-neutral-300 break-words font-mono effect-inset text-sm">
{message.text}
</div>
</div>
</div>
</div>

View File

@@ -1,141 +1,33 @@
import { useEffect } from "react";
import { PageWrapper } from "../components";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChannelDetail } from "../components/dashboard";
import { useAppSelector } from "../hooks";
import { PageWrapper, MessageBubble } from "../components";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { ChevronLeft } from "lucide-react";
export const Route = createFileRoute("/channel/$channelId")({
component: ChannelPage,
});
// Generate a deterministic color based on channel ID
const getChannelColor = (channelId: string) => {
const colors = [
"bg-green-500",
"bg-blue-500",
"bg-amber-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-red-500",
"bg-teal-500",
];
// Use a hash of the channel ID to pick a consistent color
const hash = channelId
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
function ChannelPage() {
const { channelId } = Route.useParams();
const navigate = useNavigate();
const { channels } = useAppSelector((state) => state.aggregator);
const { channels, messages, nodes } = useAppSelector(
(state) => state.aggregator
);
const channel = channels[channelId];
// Create the channel key in the same format used by the aggregator
const channelKey = `channel_${channelId}`;
// Get channel messages and sort by timestamp (oldest to newest)
// This creates a shallow copy of the message array to sort it without modifying the original
const channelMessages = messages[channelKey]
? [...messages[channelKey]].sort((a, b) => a.timestamp - b.timestamp)
: [];
// Navigation timeout if channel doesn't exist
useEffect(() => {
if (!channel) {
console.log(`[Channel] Channel ${channelId} not found`);
// Navigate back to channels page if this channel doesn't exist
setTimeout(() => navigate({ to: "/channels" }), 500);
} else {
console.log(
`[Channel] Displaying ${channelMessages.length} messages for channel ${channelId}`
);
console.log(
`[Channel] Message count from channel data: ${channel.messageCount}`
);
console.log(`[Channel] Available messages:`, messages);
console.log(`[Channel] Looking for key:`, channelKey);
if (!channels[channelId]) {
console.log(`[Channel] Channel ${channelId} not found, redirecting...`);
const timeout = setTimeout(() => {
navigate({ to: "/channels" });
}, 3000);
return () => clearTimeout(timeout);
}
}, [
channel,
channelId,
channelKey,
channelMessages.length,
navigate,
messages,
]);
if (!channel) {
return (
<PageWrapper>
<div className="text-center py-10">
<p className="text-neutral-400">Channel not found. Redirecting...</p>
</div>
</PageWrapper>
);
}
// Get the channel color based on its ID
const channelColor = getChannelColor(channelId);
}, [channelId, navigate, channels]);
return (
<PageWrapper>
<div className="max-w-4xl h-full flex flex-col">
{/* Header */}
<div className="flex items-center mb-4">
<Link
to="/channels"
className="mr-3 text-neutral-400 hover:text-neutral-200 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full ${channelColor} flex items-center justify-center text-white text-sm font-bold`}
>
{channelId.substring(0, 2).toUpperCase()}
</div>
<div className="ml-3">
<h1 className="text-xl font-semibold text-neutral-100">
{channelId}
</h1>
<p className="text-sm text-neutral-400">
{channel.nodes.length} nodes · {channel.textMessageCount}{" "}
messages
</p>
</div>
</div>
</div>
{/* Message List */}
<div className="flex-1 overflow-y-auto pb-4">
{channelMessages.length === 0 ? (
<div className="bg-neutral-800 rounded-lg p-6 text-center text-neutral-400">
<p>No messages in this channel yet.</p>
</div>
) : (
<div className="space-y-4">
{channelMessages.map((message) => {
const nodeName =
nodes[message.from]?.shortName ||
nodes[message.from]?.longName;
return (
<MessageBubble
key={`${message.from}-${message.id}`}
message={message}
nodeName={nodeName}
/>
);
})}
</div>
)}
</div>
</div>
<ChannelDetail channelId={channelId} />
</PageWrapper>
);
}