Improve NodeDetailsDialog UI and add security info (#770)

* Improve NodeDetailsDialog UI and add security info

Refactored NodeDetailsDialog to use tables for better layout and readability, added a security section displaying public key and verification status, and included messageable status. Updated i18n files with new keys and improved battery level formatting. Fixed logic in Nodes page for handling location packets and improved hardware model sorting.

* Update NodeDetailsDialog.tsx
This commit is contained in:
Jeremy Gallant
2025-08-11 03:58:26 +02:00
committed by GitHub
parent 2735c37fad
commit 176d554ef9
4 changed files with 160 additions and 80 deletions

View File

@@ -107,5 +107,7 @@
"managed": "At least one admin key is requred if the node is managed.",
"key": "Key is required."
}
}
},
"yes": "Yes",
"no": "No"
}

View File

@@ -93,7 +93,7 @@
"deviceMetrics": "Device Metrics:",
"hardware": "Hardware: ",
"lastHeard": "Last Heard: ",
"nodeHexPrefix": "Node Hex: !",
"nodeHexPrefix": "Node Hex: ",
"nodeNumber": "Node Number: ",
"position": "Position:",
"role": "Role: ",
@@ -102,7 +102,12 @@
"title": "Node Details for {{identifier}}",
"ignoreNode": "Ignore node",
"removeNode": "Remove node",
"unignoreNode": "Unignore node"
"unignoreNode": "Unignore node",
"security": "Security:",
"publicKey": "Public Key: ",
"messageable": "Messageable: ",
"KeyManuallyVerifiedTrue": "Public Key has been manually verified",
"KeyManuallyVerifiedFalse": "Public Key is not manually verified"
},
"pkiBackup": {
"loseKeysWarning": "If you lose your keys, you will need to reset your device.",

View File

@@ -33,6 +33,7 @@ import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useNavigate } from "@tanstack/react-router";
import { fromByteArray } from "base64-js";
import {
BellIcon,
BellOffIcon,
@@ -167,7 +168,8 @@ export const NodeDetailsDialog = ({
key: "batteryLevel",
label: t("nodeDetails.batteryLevel"),
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
format: (val: number) =>
val === 101 ? t("batteryStatus.pluggedIn") : `${val.toFixed(2)}%`,
},
{
key: "voltage",
@@ -177,6 +179,9 @@ export const NodeDetailsDialog = ({
},
];
const sectionClassName =
"text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-4 rounded-lg mt-3";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
@@ -192,7 +197,7 @@ export const NodeDetailsDialog = ({
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="w-full ">
<div className="flex flex-row flex-wrap space-y-1">
<Button
className="mr-1"
@@ -270,79 +275,134 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold">
{t("nodeDetails.details")}
</p>
<p>
{t("nodeDetails.nodeNumber")}
{node.num}
</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p>
{t("nodeDetails.role")}
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0 ? (
t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</p>
<p>
{t("nodeDetails.hardware")}
{(
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName")
).replace(/_/g, " ")}
</p>
<table className="table-fixed w-full">
<tbody>
<tr>
<td>{t("nodeDetails.nodeNumber")}</td>
<td>{node.num}</td>
</tr>
<tr>
<td>{t("nodeDetails.nodeHexPrefix")}</td>
<td>!{numberToHexUnpadded(node.num)}</td>
</tr>
<tr>
<td>{t("nodeDetails.role")}</td>
<td>
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
]?.replace(/_/g, " ")}
</td>
</tr>
<tr>
<td>{t("nodeDetails.lastHeard")}</td>
<td>
{node.lastHeard === 0 ? (
t("nodesTable.lastHeardStatus.never", {
ns: "nodes",
})
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</td>
</tr>
<tr>
<td>{t("nodeDetails.hardware")}</td>
<td>
{(
Protobuf.Mesh.HardwareModel[
node.user?.hwModel ?? 0
] ?? t("unknown.shortName")
).replace(/_/g, " ")}
</td>
</tr>
<tr>
<td>{t("nodeDetails.messageable")}</td>
<td>
{node.user?.isUnmessagable ? t("no") : t("yes")}
</td>
</tr>
</tbody>
</table>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
className="w-40 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
"UNKNOWN"
}
/>
</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">
<div className={sectionClassName}>
<p className="text-lg font-semibold">
{t("nodeDetails.security")}
</p>
<table className="table-auto w-full">
<tbody>
<tr>
<td className="pr-2">{t("nodeDetails.publicKey")}</td>
<td>
<pre className="text-xs pt-0.5">
{node.user?.publicKey &&
node.user?.publicKey.length > 0
? fromByteArray(node.user.publicKey)
: t("unknown.longName")}
</pre>
</td>
</tr>
<tr>
<td></td>
<td>
{node.isKeyManuallyVerified
? t("nodeDetails.KeyManuallyVerifiedTrue")
: t("nodeDetails.KeyManuallyVerifiedFalse")}
</td>
</tr>
</tbody>
</table>
</div>
<div className={sectionClassName}>
<p className="text-lg font-semibold">
{t("nodeDetails.position")}
</p>
{node.position ? (
<>
{node.position.latitudeI && node.position.longitudeI && (
<p>
{t("locationResponse.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>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
<table className="table-auto w-full">
<tbody>
{node.position.latitudeI && node.position.longitudeI && (
<tr>
<td>{t("locationResponse.coordinates")}</td>
<td>
<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>
</td>
</tr>
)}
{node.position.altitude && (
<tr>
<td>{t("locationResponse.altitude")}</td>
<td>
{node.position.altitude}
{t("unit.meter.suffix")}
</td>
</tr>
)}
</tbody>
</table>
) : (
<p>{t("unknown.shortName")}</p>
<p>{t("unknown.longName")}</p>
)}
<Button
onClick={handleRequestPosition}
@@ -355,28 +415,37 @@ export const NodeDetailsDialog = ({
</div>
{node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className={sectionClassName}>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<p key={metric.key}>
{metric.label}: {metric.format(metric?.value ?? 0)}
</p>
))}
{node.deviceMetrics.uptimeSeconds && (
<p>
{t("nodeDetails.uptime")}
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p>
)}
<table className="table-fixed w-full">
<tbody>
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<tr key={metric.key}>
<td>{metric.label}: </td>
<td>{metric.format(metric?.value ?? 0)}</td>
</tr>
))}
{node.deviceMetrics.uptimeSeconds && (
<tr>
<td>{t("nodeDetails.uptime")}</td>
<td>
<Uptime
seconds={node.deviceMetrics.uptimeSeconds}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>

View File

@@ -72,7 +72,10 @@ const NodesPage = (): JSX.Element => {
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) {
if (
location.to.valueOf() !== hardware.myNodeNum ||
location.from.valueOf() === hardware.myNodeNum
) {
return;
}
setSelectedLocation(location);
@@ -213,7 +216,8 @@ const NodesPage = (): JSX.Element => {
content: (
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
sortValue:
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNSET",
},
{
content: <Mono>{macAddress}</Mono>,