From 9399104914d023fee57521a12336fc3cf55c90b7 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Tue, 18 Mar 2025 15:21:58 -0400 Subject: [PATCH] fix: resolved issues with styling --- src/components/Dialog/DialogManager.tsx | 2 +- src/components/Dialog/NodeDetailsDialog.tsx | 190 ------------------ .../NodeDetailsDialog.test.tsx | 73 +++++++ .../NodeDetailsDialog/NodeDetailsDialog.tsx | 177 ++++++++++++++++ .../Messages/TraceRoute.test.tsx | 95 +++++++++ .../PageComponents/Messages/TraceRoute.tsx | 78 +++---- 6 files changed, 389 insertions(+), 226 deletions(-) delete mode 100644 src/components/Dialog/NodeDetailsDialog.tsx create mode 100644 src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx create mode 100644 src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx create mode 100644 src/components/PageComponents/Messages/TraceRoute.test.tsx diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index e8d597d4..0c698823 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -6,7 +6,7 @@ import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx"; import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; -import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx"; +import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; export const DialogManager = () => { diff --git a/src/components/Dialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog.tsx deleted file mode 100644 index 2f90bae2..00000000 --- a/src/components/Dialog/NodeDetailsDialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useAppStore } from "../../core/stores/appStore.ts"; -import { useDevice } from "../../core/stores/deviceStore.ts"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "../UI/Accordion.tsx"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../UI/Dialog.tsx"; -import { Protobuf } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { DeviceImage } from "../generic/DeviceImage.tsx"; -import { TimeAgo } from "../generic/TimeAgo.tsx"; -import { Uptime } from "../generic/Uptime.tsx"; - -export interface NodeDetailsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const NodeDetailsDialog = ({ - open, - onOpenChange, -}: NodeDetailsDialogProps) => { - const { nodes } = useDevice(); - const { nodeNumDetails } = useAppStore(); - const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails); - - return device - ? ( - - - - - - Node Details for {device.user?.longName ?? "UNKNOWN"} ( - {device.user?.shortName ?? "UNK"}) - - - -
- -
-

- Details: -

-

- Hardware:{" "} - {Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} -

-

Node Number: {device.num}

-

Node HEX: !{numberToHexUnpadded(device.num)}

-

- Role: {Protobuf.Config.Config_DeviceConfig_Role[ - device.user?.role ?? 0 - ]} -

-

- Last Heard: {device.lastHeard === 0 - ? ( - "Never" - ) - : } -

-
- - {device.position - ? ( -
-

- Position: -

- {device.position.latitudeI && device.position.longitudeI - ? ( -

- Coordinates:{" "} - - {device.position.latitudeI / 1e7},{" "} - {device.position.longitudeI / 1e7} - -

- ) - : null} - {device.position.altitude - ?

Altitude: {device.position.altitude}m

- : null} -
- ) - : null} - - {device.deviceMetrics - ? ( -
-

- Device Metrics: -

- {device.deviceMetrics.airUtilTx - ? ( -

- Air TX utilization:{" "} - {device.deviceMetrics.airUtilTx.toFixed(2)}% -

- ) - : null} - {device.deviceMetrics.channelUtilization - ? ( -

- Channel utilization:{" "} - {device.deviceMetrics.channelUtilization.toFixed(2)}% -

- ) - : null} - {device.deviceMetrics.batteryLevel - ? ( -

- Battery level:{" "} - {device.deviceMetrics.batteryLevel.toFixed(2)}% -

- ) - : null} - {device.deviceMetrics.voltage - ? ( -

- Voltage: {device.deviceMetrics.voltage.toFixed(2)}V -

- ) - : null} - {device.deviceMetrics.uptimeSeconds - ? ( -

- Uptime:{" "} - -

- ) - : null} -
- ) - : null} - - {device - ? ( -
- - - -

- All Raw Metrics: -

-
- -
-                            {JSON.stringify(device, null, 2)}
-                          
-
-
-
-
- ) - : null} -
-
-
-
- ) - : null; -}; diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx new file mode 100644 index 00000000..d8d1bea9 --- /dev/null +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, vi, expect, beforeEach, Mock } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { useAppStore } from "@core/stores/appStore.ts"; + +vi.mock("@core/stores/deviceStore"); +vi.mock("@core/stores/appStore"); + +describe("NodeDetailsDialog", () => { + const mockDevice = { + num: 1234, + user: { + longName: "Test Node", + shortName: "TN", + hwModel: 1, + role: 1, + }, + lastHeard: 1697500000, + position: { + latitudeI: 450000000, + longitudeI: -750000000, + altitude: 200, + }, + deviceMetrics: { + airUtilTx: 50.123, + channelUtilization: 75.456, + batteryLevel: 88.789, + voltage: 4.2, + uptimeSeconds: 3600, + }, + }; + + beforeEach(() => { + // Reset mocks before each test + vi.resetAllMocks(); + + (useDevice as Mock).mockReturnValue({ + nodes: new Map([[1234, mockDevice]]), + }); + + (useAppStore as unknown as Mock).mockReturnValue({ + nodeNumDetails: 1234, + }); + }); + + it("renders node details correctly", () => { + render( { }} />); + + expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); + + expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); + + expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); + expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument(); + expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); + expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); + expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); + expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); + expect(screen.getByText("45, -75")).toBeInTheDocument(); + expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); + expect(screen.getByText(/Role:/i)).toBeInTheDocument(); + }); + + it("renders null if device is not found", () => { + (useDevice as Mock).mockReturnValue({ + nodes: new Map(), + }); + + render( { }} />); + expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx new file mode 100644 index 00000000..f010fc67 --- /dev/null +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -0,0 +1,177 @@ +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@components/UI/Accordion.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + 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"; + +export interface NodeDetailsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const NodeDetailsDialog = ({ + open, + onOpenChange, +}: NodeDetailsDialogProps) => { + const { nodes } = useDevice(); + const { nodeNumDetails } = useAppStore(); + + const device = nodes.get(nodeNumDetails); + + if (!device) return null; + + const deviceMetricsMap = [ + { + key: "airUtilTx", + label: "Air TX utilization", + value: device.deviceMetrics?.airUtilTx, + format: (val: number) => `${val.toFixed(2)}%`, + }, + { + key: "channelUtilization", + label: "Channel utilization", + value: device.deviceMetrics?.channelUtilization, + format: (val: number) => `${val.toFixed(2)}%`, + }, + { + key: "batteryLevel", + label: "Battery level", + value: device.deviceMetrics?.batteryLevel, + format: (val: number) => `${val.toFixed(2)}%`, + }, + { + key: "voltage", + label: "Voltage", + value: device.deviceMetrics?.voltage, + format: (val: number) => `${val.toFixed(2)}V`, + }, + ]; + + return ( + + + + + + Node Details for {device.user?.longName ?? "UNKNOWN"} ( + {device.user?.shortName ?? "UNK"}) + + + +
+
+ +
+

Details:

+

+ Hardware:{" "} + {Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} +

+

Node Number: {device.num}

+

Node Hex: !{numberToHexUnpadded(device.num)}

+

+ Role:{" "} + { + Protobuf.Config.Config_DeviceConfig_Role[ + device.user?.role ?? 0 + ] + } +

+

+ Last Heard:{" "} + {device.lastHeard === 0 ? "Never" : } +

+
+ + {device.position && ( +
+

Position:

+ {device.position.latitudeI && device.position.longitudeI && ( +

+ Coordinates:{" "} + + {device.position.latitudeI / 1e7},{" "} + {device.position.longitudeI / 1e7} + +

+ )} + {device.position.altitude && ( +

Altitude: {device.position.altitude}m

+ )} +
+ )} + + {device.deviceMetrics && ( +
+

+ Device Metrics: +

+ {deviceMetricsMap.map( + (metric) => + metric.value !== undefined && ( +

+ {metric.label}: {metric.format(metric.value)} +

+ ) + )} + {device.deviceMetrics.uptimeSeconds && ( +

+ Uptime:{" "} + +

+ )} +
+ )} + +
+ +
+ + + +

+ All Raw Metrics: +

+
+ +
+                      {JSON.stringify(device, null, 2)}
+                    
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx new file mode 100644 index 00000000..977ef315 --- /dev/null +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; +import { useDevice } from "@core/stores/deviceStore.ts"; + +vi.mock("@core/stores/deviceStore"); + +describe("TraceRoute", () => { + const mockNodes = new Map([ + [ + 1, + { num: 1, user: { longName: "Node A" } }, + ], + [ + 2, + { num: 2, user: { longName: "Node B" } }, + ], + [ + 3, + { num: 3, user: { longName: "Node C" } }, + ], + ]); + + beforeEach(() => { + vi.resetAllMocks(); + (useDevice as Mock).mockReturnValue({ + nodes: mockNodes, + }); + }); + + it("renders the route to destination with SNR values", () => { + render( + + ); + + expect(screen.getByText("Route to destination:")).toBeInTheDocument(); + expect(screen.getByText("Destination Node")).toBeInTheDocument(); + + expect(screen.getByText("Node A")).toBeInTheDocument(); + expect(screen.getByText("Node B")).toBeInTheDocument(); + + expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops + expect(screen.getByText("↓ 10dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 20dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 30dB")).toBeInTheDocument(); + expect(screen.getByText("Source Node")).toBeInTheDocument(); + }); + + it("renders the route back when provided", () => { + render( + + ); + + expect(screen.getByText("Route back:")).toBeInTheDocument(); + expect(screen.getByText("Node C")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 45dB")).toBeInTheDocument(); + }); + + it("renders '??' for missing SNR values", () => { + render( + + ); + + expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); + }); + + it("renders hop hex if node is not found", () => { + render( + + ); + + expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex + }); +}); diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 92a00fa6..13f8f08f 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -11,6 +11,33 @@ export interface TraceRouteProps { snrBack?: Array; } +interface RoutePathProps { + title: string; + startNode?: Protobuf.Mesh.NodeInfo; + endNode?: Protobuf.Mesh.NodeInfo; + path: number[]; + snr?: number[]; +} + +const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => { + const { nodes } = useDevice(); + + return ( + +

{title}

+

{startNode?.user?.longName}

+

↓ {snr?.[0] ?? "??"}dB

+ {path.map((hop, i) => ( + +

{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}

+

↓ {snr?.[i + 1] ?? "??"}dB

+
+ ))} +

{endNode?.user?.longName}

+
+ ); +}; + export const TraceRoute = ({ from, to, @@ -19,43 +46,24 @@ export const TraceRoute = ({ snrTowards, snrBack, }: TraceRouteProps) => { - const { nodes } = useDevice(); - return (
- -

Route to destination:

-

{to?.user?.longName}

-

↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB

- {route.map((hop, i) => ( - -

- {nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} -

-

↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB

-
- ))} - {from?.user?.longName} -
- {routeBack - ? ( - -

Route back:

-

{from?.user?.longName}

-

↓ {snrBack?.[0] ? snrBack[0] : "??"}dB

- {routeBack.map((hop, i) => ( - -

- {nodes.get(hop)?.user?.longName ?? - `!${numberToHexUnpadded(hop)}`} -

-

↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB

-
- ))} - {to?.user?.longName} -
- ) - : null} + + {routeBack && ( + + )}
); };