mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Tweaks to channel list and messages
This commit is contained in:
144
web/src/components/dashboard/ChannelDetail.tsx
Normal file
144
web/src/components/dashboard/ChannelDetail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user