Add channel page

This commit is contained in:
Daniel Pupius
2025-04-24 18:01:29 -07:00
parent 69b0ea6615
commit 33fcc87f41
9 changed files with 473 additions and 76 deletions

View File

@@ -3,12 +3,55 @@ import { Link } from "@tanstack/react-router";
import { ConnectionStatus } from "./ConnectionStatus";
import { Separator } from "./Separator";
import { SITE_TITLE } from "../lib/config";
import { Info, Layers, LayoutDashboard, Radio, Palette } from "lucide-react";
import {
Info,
Layers,
LayoutDashboard,
Radio,
MessageSquare,
LucideIcon,
} from "lucide-react";
// Define navigation item structure
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
exact?: boolean;
}
// Define props for the component
interface NavProps {
connectionStatus: string;
}
// Navigation items data
const navigationItems: NavItem[] = [
{
to: "/",
label: "Dashboard",
icon: LayoutDashboard,
exact: true,
},
{
to: "/packets",
label: "Stream",
icon: Radio,
exact: true,
},
{
to: "/channels",
label: "Messages",
icon: MessageSquare,
},
{
to: "/info",
label: "Information",
icon: Info,
exact: true,
},
];
export const Nav: React.FC<NavProps> = ({ connectionStatus }) => {
return (
<aside className="w-64 text-neutral-100 h-screen fixed left-0 top-0 flex flex-col">
@@ -24,70 +67,25 @@ export const Nav: React.FC<NavProps> = ({ connectionStatus }) => {
<nav className="flex-1">
<ul className="space-y-1">
<li>
<Link
to="/"
className="flex items-center px-4 py-2.5 transition-colors"
inactiveProps={{
className: "text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: true,
className: "text-neutral-200 font-normal",
}}
>
<LayoutDashboard className="h-4 w-4 mr-3" />
Dashboard
</Link>
</li>
<li>
<Link
to="/packets"
className="flex items-center px-4 py-2.5 transition-colors "
inactiveProps={{
className: "text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: true,
className: "text-neutral-200 font-normal",
}}
>
<Radio className="h-4 w-4 mr-3" />
Stream
</Link>
</li>
<li>
<Link
to="/demo"
className="flex items-center px-4 py-2.5 transition-colors "
inactiveProps={{
className: "text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: true,
className: "text-neutral-200 font-normal",
}}
>
<Palette className="h-4 w-4 mr-3" />
Component Demo
</Link>
</li>
<li>
<Link
to="/info"
className="flex items-center px-4 py-2.5 transition-colors "
inactiveProps={{
className: "text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: true,
className: "text-neutral-200 font-normal",
}}
>
<Info className="h-4 w-4 mr-3" />
Information
</Link>
</li>
{navigationItems.map((item) => (
<li key={item.to}>
<Link
to={item.to}
className="flex items-center px-4 py-2.5 transition-colors"
inactiveProps={{
className:
"text-neutral-400 hover:text-neutral-200 font-thin",
}}
activeProps={{
exact: item.exact,
className: "text-neutral-200 font-normal",
}}
>
<item.icon className="h-4 w-4 mr-3" />
{item.label}
</Link>
</li>
))}
</ul>
</nav>

View File

@@ -9,4 +9,5 @@ export * from './Separator';
export * from './StreamControl';
export * from './PageWrapper';
export * from './Counter';
export * from './dashboard';
export * from './dashboard';
export * from './messages';

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Link } from "@tanstack/react-router";
import { ChannelData } from "../../store/slices/aggregatorSlice";
interface ChannelCardProps {
channel: ChannelData;
}
export const ChannelCard: React.FC<ChannelCardProps> = ({ channel }) => {
// Generate a random color for the channel icon
const getChannelColor = () => {
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 = channel.channelId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
// Get short identifier for channel
const getChannelInitials = () => {
// Use first 2 characters
return channel.channelId.substring(0, 2).toUpperCase();
};
// Format last message time as relative time
const formatLastMessageTime = () => {
if (!channel.lastMessage) return '';
const now = Math.floor(Date.now() / 1000);
const diff = now - channel.lastMessage;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
return (
<Link
to="/channel/$channelId"
params={{ channelId: channel.channelId }}
className="bg-neutral-800 rounded-lg p-4 hover:bg-neutral-700 transition-colors"
>
<div className="flex items-center mb-3">
<div className={`w-10 h-10 rounded-full ${getChannelColor()} flex items-center justify-center text-white text-sm font-bold`}>
{getChannelInitials()}
</div>
<div className="ml-3 flex-1 truncate">
<h3 className="text-lg text-neutral-200 font-medium truncate">{channel.channelId}</h3>
{channel.lastMessage && (
<p className="text-xs text-neutral-400">{formatLastMessageTime()}</p>
)}
</div>
</div>
<div className="flex justify-between text-neutral-400 text-sm">
<div>
<span className="text-amber-500 font-medium">{channel.textMessageCount}</span> messages
</div>
<div>
<span className="text-green-500 font-medium">{channel.nodes.length}</span> nodes
</div>
<div>
<span className="text-blue-500 font-medium">{channel.gateways.length}</span> gateways
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { TextMessage } from "../../store/slices/aggregatorSlice";
interface MessageBubbleProps {
message: TextMessage;
nodeName?: string;
}
// Helper to format timestamp
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Generate a deterministic color based on node ID
const getNodeColor = (nodeId: number) => {
const colors = [
'bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-purple-500',
'bg-pink-500', 'bg-indigo-500', 'bg-red-500', 'bg-orange-500'
];
return colors[nodeId % colors.length];
};
// Get node initials or ID fragment
const getNodeInitials = (nodeId: number, nodeName?: string) => {
if (nodeName) {
// Use first 2 characters or extract initials
if (nodeName.length <= 2) return nodeName.toUpperCase();
// Try to get initials from words
const words = nodeName.split(/\s+/);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return nodeName.substring(0, 2).toUpperCase();
}
// Use last 4 chars of hex ID
return nodeId.toString(16).slice(-4).toUpperCase();
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, nodeName }) => {
const initials = getNodeInitials(message.from, nodeName);
const nodeColor = getNodeColor(message.from);
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>
<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>
</div>
<div className="mt-1 bg-neutral-800 rounded-lg py-2 px-3 text-neutral-300 break-words">
{message.text}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from './ChannelCard';
export * from './MessageBubble';

View File

@@ -15,8 +15,10 @@ import { Route as RootImport } from './routes/root'
import { Route as PacketsImport } from './routes/packets'
import { Route as HomeImport } from './routes/home'
import { Route as DemoImport } from './routes/demo'
import { Route as ChannelsImport } from './routes/channels'
import { Route as IndexImport } from './routes/index'
import { Route as NodeNodeIdImport } from './routes/node.$nodeId'
import { Route as ChannelChannelIdImport } from './routes/channel.$channelId'
// Create/Update Routes
@@ -44,6 +46,12 @@ const DemoRoute = DemoImport.update({
getParentRoute: () => rootRoute,
} as any)
const ChannelsRoute = ChannelsImport.update({
id: '/channels',
path: '/channels',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
@@ -56,6 +64,12 @@ const NodeNodeIdRoute = NodeNodeIdImport.update({
getParentRoute: () => rootRoute,
} as any)
const ChannelChannelIdRoute = ChannelChannelIdImport.update({
id: '/channel/$channelId',
path: '/channel/$channelId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -67,6 +81,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/channels': {
id: '/channels'
path: '/channels'
fullPath: '/channels'
preLoaderRoute: typeof ChannelsImport
parentRoute: typeof rootRoute
}
'/demo': {
id: '/demo'
path: '/demo'
@@ -95,6 +116,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RootImport
parentRoute: typeof rootRoute
}
'/channel/$channelId': {
id: '/channel/$channelId'
path: '/channel/$channelId'
fullPath: '/channel/$channelId'
preLoaderRoute: typeof ChannelChannelIdImport
parentRoute: typeof rootRoute
}
'/node/$nodeId': {
id: '/node/$nodeId'
path: '/node/$nodeId'
@@ -109,63 +137,91 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/demo': typeof DemoRoute
'/home': typeof HomeRoute
'/packets': typeof PacketsRoute
'/root': typeof RootRoute
'/channel/$channelId': typeof ChannelChannelIdRoute
'/node/$nodeId': typeof NodeNodeIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/demo': typeof DemoRoute
'/home': typeof HomeRoute
'/packets': typeof PacketsRoute
'/root': typeof RootRoute
'/channel/$channelId': typeof ChannelChannelIdRoute
'/node/$nodeId': typeof NodeNodeIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/demo': typeof DemoRoute
'/home': typeof HomeRoute
'/packets': typeof PacketsRoute
'/root': typeof RootRoute
'/channel/$channelId': typeof ChannelChannelIdRoute
'/node/$nodeId': typeof NodeNodeIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
id:
| '__root__'
fullPaths:
| '/'
| '/channels'
| '/demo'
| '/home'
| '/packets'
| '/root'
| '/channel/$channelId'
| '/node/$nodeId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/channels'
| '/demo'
| '/home'
| '/packets'
| '/root'
| '/channel/$channelId'
| '/node/$nodeId'
id:
| '__root__'
| '/'
| '/channels'
| '/demo'
| '/home'
| '/packets'
| '/root'
| '/channel/$channelId'
| '/node/$nodeId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChannelsRoute: typeof ChannelsRoute
DemoRoute: typeof DemoRoute
HomeRoute: typeof HomeRoute
PacketsRoute: typeof PacketsRoute
RootRoute: typeof RootRoute
ChannelChannelIdRoute: typeof ChannelChannelIdRoute
NodeNodeIdRoute: typeof NodeNodeIdRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChannelsRoute: ChannelsRoute,
DemoRoute: DemoRoute,
HomeRoute: HomeRoute,
PacketsRoute: PacketsRoute,
RootRoute: RootRoute,
ChannelChannelIdRoute: ChannelChannelIdRoute,
NodeNodeIdRoute: NodeNodeIdRoute,
}
@@ -180,16 +236,21 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/channels",
"/demo",
"/home",
"/packets",
"/root",
"/channel/$channelId",
"/node/$nodeId"
]
},
"/": {
"filePath": "index.tsx"
},
"/channels": {
"filePath": "channels.tsx"
},
"/demo": {
"filePath": "demo.tsx"
},
@@ -202,6 +263,9 @@ export const routeTree = rootRoute
"/root": {
"filePath": "root.tsx"
},
"/channel/$channelId": {
"filePath": "channel.$channelId.tsx"
},
"/node/$nodeId": {
"filePath": "node.$nodeId.tsx"
}

View File

@@ -0,0 +1,113 @@
import { useEffect } from "react";
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, 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) :
[];
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);
}
}, [channel, channelId, 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);
return (
<PageWrapper>
<div className="max-w-4xl mx-auto 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>
</PageWrapper>
);
}

View File

@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { useAppSelector } from "../hooks";
import { PageWrapper, ChannelCard } from "../components";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/channels')({
component: ChannelsPage,
});
function ChannelsPage() {
const { channels, messages } = useAppSelector(state => state.aggregator);
useEffect(() => {
console.log(`[Channels] Displaying ${Object.keys(channels).length} channels`);
console.log(`[Channels] Messages in store:`, messages);
// Check if any channels have text messages but none are displayed
Object.entries(channels).forEach(([channelId, channel]) => {
const channelKey = `channel_${channelId}`;
if (channel.textMessageCount > 0 && (!messages[channelKey] || messages[channelKey].length === 0)) {
console.log(`[Channels] Mismatch: Channel ${channelId} reports ${channel.textMessageCount} text messages but has ${messages[channelKey]?.length || 0} in store`);
}
});
}, [channels, messages]);
// Extract and sort channels by last message time (most recent first)
const sortedChannels = Object.values(channels).sort((a, b) => {
// First sort by activity (last message time)
if (a.lastMessage && b.lastMessage) {
return b.lastMessage - a.lastMessage;
}
// If one has activity and other doesn't, prioritize the active one
if (a.lastMessage) return -1;
if (b.lastMessage) return 1;
// Fall back to text message count if no last message time
return (b.textMessageCount || 0) - (a.textMessageCount || 0);
});
return (
<PageWrapper>
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-semibold text-neutral-100 mb-6">Channels</h1>
{sortedChannels.length === 0 ? (
<div className="bg-neutral-800 rounded-lg p-6 text-center text-neutral-400">
<p>No channels discovered yet.</p>
<p className="text-sm mt-2">Channels will appear here as they are discovered on the Meshtastic network.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedChannels.map((channel) => (
<ChannelCard key={channel.channelId} channel={channel} />
))}
</div>
)}
</div>
</PageWrapper>
);
}

View File

@@ -52,6 +52,7 @@ export interface ChannelData {
gateways: string[]; // Changed from Set to array for Redux serialization
nodes: number[]; // Changed from Set to array for Redux serialization
messageCount: number;
textMessageCount: number; // Count specifically for text messages
lastMessage?: number;
}
@@ -152,12 +153,18 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
gateways: [],
nodes: [],
messageCount: 0,
textMessageCount: 0,
};
}
const channel = state.channels[channelId];
channel.messageCount++;
// Track text messages separately
if (data.textMessage) {
channel.textMessageCount++;
}
channel.lastMessage = timestamp;
if (gatewayId && !channel.gateways.includes(gatewayId)) {
@@ -212,7 +219,12 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
}
}
// Process text messages.
// Process text messages - only for new packets to avoid duplicates
// Debug text message processing
if (data.textMessage) {
console.log(`[Aggregator] Processing text message: "${data.textMessage}" on channel ${channelId}, isNewPacket: ${isNewPacket}`);
}
if (data.textMessage && nodeId !== undefined && channelId) {
const channelKey = getChannelKey(channelId);
@@ -224,15 +236,24 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
const nodeName =
state.nodes[nodeId]?.shortName || state.nodes[nodeId]?.longName;
state.messages[channelKey].push({
id: data.id || Math.random(),
// Ensure we have a stable ID for the message
const messageId = data.id !== undefined ? data.id : Math.floor(Math.random() * 1000000);
const newMessage = {
id: messageId,
from: nodeId,
fromName: nodeName,
text: data.textMessage,
timestamp,
channelId,
gatewayId: gatewayId || "",
});
};
state.messages[channelKey].push(newMessage);
console.log(`[Aggregator] Added message to channel ${channelId}:`, newMessage);
console.log(`[Aggregator] Channel ${channelId} now has ${state.messages[channelKey].length} messages`);
// Sort messages by timestamp (newest first)
state.messages[channelKey].sort((a, b) => b.timestamp - a.timestamp);