|
|
|
@ -1,5 +1,18 @@ |
|
|
|
import { useEffect, useState } from "react"; |
|
|
|
|
|
|
|
import { useAppStore } from "@core/stores/appStore.ts"; |
|
|
|
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
|
|
import { |
|
|
|
MessageType, |
|
|
|
useMessageStore, |
|
|
|
} from "@core/stores/messageStore/index.ts"; |
|
|
|
import { Protobuf } from "@meshtastic/core"; |
|
|
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|
|
|
import { DeviceImage } from "@components/generic/DeviceImage.tsx"; |
|
|
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|
|
|
import { Uptime } from "@components/generic/Uptime.tsx"; |
|
|
|
import { toast } from "@core/hooks/useToast.ts"; |
|
|
|
|
|
|
|
import { |
|
|
|
Accordion, |
|
|
|
AccordionContent, |
|
|
|
@ -14,51 +27,122 @@ import { |
|
|
|
DialogHeader, |
|
|
|
DialogTitle, |
|
|
|
} from "@components/UI/Dialog.tsx"; |
|
|
|
import { Protobuf } from "@meshtastic/core"; |
|
|
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|
|
|
import { DeviceImage } from "@components/generic/DeviceImage.tsx"; |
|
|
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|
|
|
import { Uptime } from "@components/generic/Uptime.tsx"; |
|
|
|
import { Button } from "@components/UI/Button.tsx"; |
|
|
|
|
|
|
|
import { |
|
|
|
EarIcon, |
|
|
|
EarOffIcon, |
|
|
|
MapPinnedIcon, |
|
|
|
MessageSquareIcon, |
|
|
|
StarIcon, |
|
|
|
TrashIcon, |
|
|
|
WaypointsIcon, |
|
|
|
} from "lucide-react"; |
|
|
|
import { |
|
|
|
Tooltip, |
|
|
|
TooltipArrow, |
|
|
|
TooltipContent, |
|
|
|
TooltipProvider, |
|
|
|
TooltipTrigger, |
|
|
|
} from "@components/UI/Tooltip.tsx"; |
|
|
|
import { Separator } from "@components/UI/Seperator.tsx"; |
|
|
|
|
|
|
|
export interface NodeDetailsDialogProps { |
|
|
|
node: Protobuf.Mesh.NodeInfo | undefined; |
|
|
|
open: boolean; |
|
|
|
onOpenChange: (open: boolean) => void; |
|
|
|
} |
|
|
|
|
|
|
|
export const NodeDetailsDialog = ({ |
|
|
|
node, |
|
|
|
open, |
|
|
|
onOpenChange, |
|
|
|
}: NodeDetailsDialogProps) => { |
|
|
|
const { getNode } = useDevice(); |
|
|
|
const { nodeNumDetails } = useAppStore(); |
|
|
|
const { setDialogOpen, connection, setActivePage, setFavorite } = useDevice(); |
|
|
|
const { setNodeNumToBeRemoved } = useAppStore(); |
|
|
|
const { setChatType, setActiveChat } = useMessageStore(); |
|
|
|
const [isFavouriteState, setIsFavoriteState] = useState<boolean>(false); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!node) return; |
|
|
|
setIsFavoriteState(node?.isFavorite); |
|
|
|
}, [node]); |
|
|
|
|
|
|
|
if (!node) return null; |
|
|
|
|
|
|
|
function handleDirectMessage() { |
|
|
|
if (!node) return null; |
|
|
|
|
|
|
|
setChatType(MessageType.Direct); |
|
|
|
setActiveChat(node.num); |
|
|
|
setActivePage("messages"); |
|
|
|
} |
|
|
|
|
|
|
|
function handleRequestPosition() { |
|
|
|
if (!node) return null; |
|
|
|
|
|
|
|
toast({ |
|
|
|
title: "Requesting position, please wait...", |
|
|
|
}); |
|
|
|
connection?.requestPosition(node.num).then(() => |
|
|
|
toast({ |
|
|
|
title: "Position request sent.", |
|
|
|
}) |
|
|
|
); |
|
|
|
onOpenChange(false); |
|
|
|
} |
|
|
|
|
|
|
|
function handleTraceroute() { |
|
|
|
if (!node) return null; |
|
|
|
|
|
|
|
const device = getNode(nodeNumDetails); |
|
|
|
toast({ |
|
|
|
title: "Sending Traceroute, please wait...", |
|
|
|
}); |
|
|
|
connection?.traceRoute(node.num).then(() => |
|
|
|
toast({ |
|
|
|
title: "Traceroute sent.", |
|
|
|
}) |
|
|
|
); |
|
|
|
onOpenChange(false); |
|
|
|
} |
|
|
|
|
|
|
|
if (!device) return null; |
|
|
|
function handleNodeRemove() { |
|
|
|
if (!node) return null; |
|
|
|
|
|
|
|
setNodeNumToBeRemoved(node?.num); |
|
|
|
setDialogOpen("nodeRemoval", true); |
|
|
|
onOpenChange(false); |
|
|
|
} |
|
|
|
|
|
|
|
function handleToggleFavourite() { |
|
|
|
if (!node) return null; |
|
|
|
setFavorite(node.num, !isFavouriteState); |
|
|
|
setIsFavoriteState(!isFavouriteState); |
|
|
|
} |
|
|
|
|
|
|
|
const deviceMetricsMap = [ |
|
|
|
{ |
|
|
|
key: "airUtilTx", |
|
|
|
label: "Air TX utilization", |
|
|
|
value: device.deviceMetrics?.airUtilTx, |
|
|
|
value: node.deviceMetrics?.airUtilTx, |
|
|
|
format: (val: number) => `${val.toFixed(2)}%`, |
|
|
|
}, |
|
|
|
{ |
|
|
|
key: "channelUtilization", |
|
|
|
label: "Channel utilization", |
|
|
|
value: device.deviceMetrics?.channelUtilization, |
|
|
|
value: node.deviceMetrics?.channelUtilization, |
|
|
|
format: (val: number) => `${val.toFixed(2)}%`, |
|
|
|
}, |
|
|
|
{ |
|
|
|
key: "batteryLevel", |
|
|
|
label: "Battery level", |
|
|
|
value: device.deviceMetrics?.batteryLevel, |
|
|
|
value: node.deviceMetrics?.batteryLevel, |
|
|
|
format: (val: number) => `${val.toFixed(2)}%`, |
|
|
|
}, |
|
|
|
{ |
|
|
|
key: "voltage", |
|
|
|
label: "Voltage", |
|
|
|
value: device.deviceMetrics?.voltage, |
|
|
|
value: node.deviceMetrics?.voltage, |
|
|
|
format: (val: number) => `${val.toFixed(2)}V`, |
|
|
|
}, |
|
|
|
]; |
|
|
|
@ -69,64 +153,133 @@ export const NodeDetailsDialog = ({ |
|
|
|
<DialogClose /> |
|
|
|
<DialogHeader> |
|
|
|
<DialogTitle> |
|
|
|
Node Details for {device.user?.longName ?? "UNKNOWN"} ( |
|
|
|
{device.user?.shortName ?? "UNK"}) |
|
|
|
Node Details for {node.user?.longName ?? "UNKNOWN"} ( |
|
|
|
{node.user?.shortName ?? "UNK"}) |
|
|
|
</DialogTitle> |
|
|
|
</DialogHeader> |
|
|
|
<DialogFooter> |
|
|
|
<div className="w-full"> |
|
|
|
<div className="flex flex-col"> |
|
|
|
<DeviceImage |
|
|
|
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800" |
|
|
|
deviceType={Protobuf.Mesh |
|
|
|
.HardwareModel[device.user?.hwModel ?? 0]} |
|
|
|
/> |
|
|
|
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
|
|
<p className="text-lg font-semibold">Details:</p> |
|
|
|
<p> |
|
|
|
Hardware:{" "} |
|
|
|
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} |
|
|
|
</p> |
|
|
|
<p>Node Number: {device.num}</p> |
|
|
|
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p> |
|
|
|
<p> |
|
|
|
Role: {Protobuf.Config.Config_DeviceConfig_Role[ |
|
|
|
device.user?.role ?? 0 |
|
|
|
]} |
|
|
|
</p> |
|
|
|
<p> |
|
|
|
Last Heard: {device.lastHeard === 0 |
|
|
|
? "Never" |
|
|
|
: <TimeAgo timestamp={device.lastHeard * 1000} />} |
|
|
|
</p> |
|
|
|
</div> |
|
|
|
<div className="flex flex-row flex-wrap space-y-1"> |
|
|
|
<Button className="mr-1" onClick={handleDirectMessage}> |
|
|
|
<MessageSquareIcon className="mr-2" /> |
|
|
|
Message |
|
|
|
</Button> |
|
|
|
<Button className="mr-1" onClick={handleTraceroute}> |
|
|
|
<WaypointsIcon className="mr-2" /> |
|
|
|
Trace Route |
|
|
|
</Button> |
|
|
|
<Button className="mr-1" onClick={handleToggleFavourite}> |
|
|
|
<StarIcon |
|
|
|
className={isFavouriteState ? " fill-yellow-400" : ""} |
|
|
|
/> |
|
|
|
</Button> |
|
|
|
<div className="flex flex-1 justify-start"></div> |
|
|
|
|
|
|
|
{device.position && ( |
|
|
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
|
|
<p className="text-lg font-semibold">Position:</p> |
|
|
|
{device.position.latitudeI && device.position.longitudeI && ( |
|
|
|
<p> |
|
|
|
Coordinates:{" "} |
|
|
|
<a |
|
|
|
className="text-blue-500 dark:text-blue-400" |
|
|
|
href={`https://www.openstreetmap.org/?mlat=${ |
|
|
|
device.position.latitudeI / 1e7 |
|
|
|
}&mlon=${device.position.longitudeI / 1e7}&layers=N`}
|
|
|
|
target="_blank" |
|
|
|
rel="noreferrer" |
|
|
|
> |
|
|
|
{device.position.latitudeI / 1e7},{" "} |
|
|
|
{device.position.longitudeI / 1e7} |
|
|
|
</a> |
|
|
|
</p> |
|
|
|
)} |
|
|
|
{device.position.altitude && ( |
|
|
|
<p>Altitude: {device.position.altitude}m</p> |
|
|
|
)} |
|
|
|
<TooltipProvider delayDuration={300}> |
|
|
|
<Tooltip> |
|
|
|
<TooltipTrigger asChild> |
|
|
|
<Button |
|
|
|
variant={node.isIgnored ? "default" : "destructive"} |
|
|
|
className="flex justify-end mr-1" |
|
|
|
> |
|
|
|
{node.isIgnored ? <EarIcon /> : <EarOffIcon />} |
|
|
|
</Button> |
|
|
|
</TooltipTrigger> |
|
|
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> |
|
|
|
{node.isIgnored ? "Unignore node" : "Ignore node"} |
|
|
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> |
|
|
|
</TooltipContent> |
|
|
|
</Tooltip> |
|
|
|
</TooltipProvider> |
|
|
|
|
|
|
|
<TooltipProvider delayDuration={300}> |
|
|
|
<Tooltip> |
|
|
|
<TooltipTrigger asChild> |
|
|
|
<Button |
|
|
|
variant="destructive" |
|
|
|
className="flex justify-end" |
|
|
|
onClick={handleNodeRemove} |
|
|
|
> |
|
|
|
<TrashIcon /> |
|
|
|
</Button> |
|
|
|
</TooltipTrigger> |
|
|
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> |
|
|
|
Remove node |
|
|
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> |
|
|
|
</TooltipContent> |
|
|
|
</Tooltip> |
|
|
|
</TooltipProvider> |
|
|
|
</div> |
|
|
|
|
|
|
|
<Separator className="mt-5 mb-2" /> |
|
|
|
|
|
|
|
<div className="flex flex-col flex-wrap space-x-1 space-y-1"> |
|
|
|
<div className="flex flex-row space-x-2"> |
|
|
|
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg"> |
|
|
|
<p className="text-lg font-semibold">Details:</p> |
|
|
|
<p>Node Number: {node.num}</p> |
|
|
|
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p> |
|
|
|
<p> |
|
|
|
Role: {Protobuf.Config.Config_DeviceConfig_Role[ |
|
|
|
node.user?.role ?? 0 |
|
|
|
].replace(/_/g, " ")} |
|
|
|
</p> |
|
|
|
<p> |
|
|
|
Last Heard: {node.lastHeard === 0 |
|
|
|
? "Never" |
|
|
|
: <TimeAgo timestamp={node.lastHeard * 1000} />} |
|
|
|
</p> |
|
|
|
<p> |
|
|
|
Hardware:{" "} |
|
|
|
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] |
|
|
|
.replace(/_/g, " ")} |
|
|
|
</p> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
<DeviceImage |
|
|
|
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800" |
|
|
|
deviceType={Protobuf.Mesh |
|
|
|
.HardwareModel[node.user?.hwModel ?? 0]} |
|
|
|
/> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div> |
|
|
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
|
|
<p className="text-lg font-semibold">Position:</p> |
|
|
|
|
|
|
|
{node.position |
|
|
|
? ( |
|
|
|
<> |
|
|
|
{node.position.latitudeI && |
|
|
|
node.position.longitudeI && ( |
|
|
|
<p> |
|
|
|
Coordinates:{" "} |
|
|
|
<a |
|
|
|
className="text-blue-500 dark:text-blue-400" |
|
|
|
href={`https://www.openstreetmap.org/?mlat=${ |
|
|
|
node.position.latitudeI / 1e7 |
|
|
|
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
|
|
|
|
target="_blank" |
|
|
|
rel="noreferrer" |
|
|
|
> |
|
|
|
{node.position.latitudeI / 1e7},{" "} |
|
|
|
{node.position.longitudeI / 1e7} |
|
|
|
</a> |
|
|
|
</p> |
|
|
|
)} |
|
|
|
{node.position.altitude && ( |
|
|
|
<p>Altitude: {node.position.altitude}m</p> |
|
|
|
)} |
|
|
|
</> |
|
|
|
) |
|
|
|
: <p>Unknown</p>} |
|
|
|
<Button onClick={handleRequestPosition}> |
|
|
|
<MapPinnedIcon className="mr-2" /> |
|
|
|
Request Position |
|
|
|
</Button> |
|
|
|
</div> |
|
|
|
|
|
|
|
{device.deviceMetrics && ( |
|
|
|
{node.deviceMetrics && ( |
|
|
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
|
|
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100"> |
|
|
|
Device Metrics: |
|
|
|
@ -139,10 +292,10 @@ export const NodeDetailsDialog = ({ |
|
|
|
</p> |
|
|
|
), |
|
|
|
)} |
|
|
|
{device.deviceMetrics.uptimeSeconds && ( |
|
|
|
{node.deviceMetrics.uptimeSeconds && ( |
|
|
|
<p> |
|
|
|
Uptime:{" "} |
|
|
|
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> |
|
|
|
<Uptime seconds={node.deviceMetrics.uptimeSeconds} /> |
|
|
|
</p> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
@ -159,7 +312,7 @@ export const NodeDetailsDialog = ({ |
|
|
|
</AccordionTrigger> |
|
|
|
<AccordionContent className="overflow-x-scroll"> |
|
|
|
<pre className="text-xs w-full"> |
|
|
|
{JSON.stringify(device, null, 2)} |
|
|
|
{JSON.stringify(node, null, 2)} |
|
|
|
</pre> |
|
|
|
</AccordionContent> |
|
|
|
</AccordionItem> |
|
|
|
|