committed by
GitHub
6 changed files with 389 additions and 226 deletions
@ -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 |
|
||||
? ( |
|
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||
<DialogContent> |
|
||||
<DialogClose /> |
|
||||
<DialogHeader> |
|
||||
<DialogTitle> |
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} ( |
|
||||
{device.user?.shortName ?? "UNK"}) |
|
||||
</DialogTitle> |
|
||||
</DialogHeader> |
|
||||
<DialogFooter> |
|
||||
<div className="w-full"> |
|
||||
<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 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|
||||
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> |
|
||||
|
|
||||
{device.position |
|
||||
? ( |
|
||||
<div className="mt-5 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-50"> |
|
||||
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> |
|
||||
) |
|
||||
: null} |
|
||||
{device.position.altitude |
|
||||
? <p>Altitude: {device.position.altitude}m</p> |
|
||||
: null} |
|
||||
</div> |
|
||||
) |
|
||||
: null} |
|
||||
|
|
||||
{device.deviceMetrics |
|
||||
? ( |
|
||||
<div className="mt-5 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-50"> |
|
||||
Device Metrics: |
|
||||
</p> |
|
||||
{device.deviceMetrics.airUtilTx |
|
||||
? ( |
|
||||
<p> |
|
||||
Air TX utilization:{" "} |
|
||||
{device.deviceMetrics.airUtilTx.toFixed(2)}% |
|
||||
</p> |
|
||||
) |
|
||||
: null} |
|
||||
{device.deviceMetrics.channelUtilization |
|
||||
? ( |
|
||||
<p> |
|
||||
Channel utilization:{" "} |
|
||||
{device.deviceMetrics.channelUtilization.toFixed(2)}% |
|
||||
</p> |
|
||||
) |
|
||||
: null} |
|
||||
{device.deviceMetrics.batteryLevel |
|
||||
? ( |
|
||||
<p> |
|
||||
Battery level:{" "} |
|
||||
{device.deviceMetrics.batteryLevel.toFixed(2)}% |
|
||||
</p> |
|
||||
) |
|
||||
: null} |
|
||||
{device.deviceMetrics.voltage |
|
||||
? ( |
|
||||
<p> |
|
||||
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V |
|
||||
</p> |
|
||||
) |
|
||||
: null} |
|
||||
{device.deviceMetrics.uptimeSeconds |
|
||||
? ( |
|
||||
<p> |
|
||||
Uptime:{" "} |
|
||||
<Uptime |
|
||||
seconds={device.deviceMetrics.uptimeSeconds} |
|
||||
/> |
|
||||
</p> |
|
||||
) |
|
||||
: null} |
|
||||
</div> |
|
||||
) |
|
||||
: null} |
|
||||
|
|
||||
{device |
|
||||
? ( |
|
||||
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|
||||
<Accordion |
|
||||
className="AccordionRoot" |
|
||||
type="single" |
|
||||
collapsible |
|
||||
> |
|
||||
<AccordionItem className="AccordionItem" value="item-1"> |
|
||||
<AccordionTrigger> |
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|
||||
All Raw Metrics: |
|
||||
</p> |
|
||||
</AccordionTrigger> |
|
||||
<AccordionContent className="overflow-x-scroll"> |
|
||||
<pre className="text-xs w-full"> |
|
||||
{JSON.stringify(device, null, 2)} |
|
||||
</pre> |
|
||||
</AccordionContent> |
|
||||
</AccordionItem> |
|
||||
</Accordion> |
|
||||
</div> |
|
||||
) |
|
||||
: null} |
|
||||
</div> |
|
||||
</DialogFooter> |
|
||||
</DialogContent> |
|
||||
</Dialog> |
|
||||
) |
|
||||
: null; |
|
||||
}; |
|
||||
@ -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(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); |
||||
|
|
||||
|
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(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); |
||||
|
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
@ -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 ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent > |
||||
|
<DialogClose /> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle> |
||||
|
Node Details for {device.user?.longName ?? "UNKNOWN"} ( |
||||
|
{device.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> |
||||
|
|
||||
|
{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> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{device.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-50"> |
||||
|
Device Metrics: |
||||
|
</p> |
||||
|
{deviceMetricsMap.map( |
||||
|
(metric) => |
||||
|
metric.value !== undefined && ( |
||||
|
<p key={metric.key}> |
||||
|
{metric.label}: {metric.format(metric.value)} |
||||
|
</p> |
||||
|
) |
||||
|
)} |
||||
|
{device.deviceMetrics.uptimeSeconds && ( |
||||
|
<p> |
||||
|
Uptime:{" "} |
||||
|
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> |
||||
|
</p> |
||||
|
)} |
||||
|
</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"> |
||||
|
<Accordion className="AccordionRoot" type="single" collapsible> |
||||
|
<AccordionItem className="AccordionItem" value="item-1"> |
||||
|
<AccordionTrigger> |
||||
|
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
||||
|
All Raw Metrics: |
||||
|
</p> |
||||
|
</AccordionTrigger> |
||||
|
<AccordionContent className="overflow-x-scroll"> |
||||
|
<pre className="text-xs w-full"> |
||||
|
{JSON.stringify(device, null, 2)} |
||||
|
</pre> |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
</Accordion> |
||||
|
</div> |
||||
|
</div> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
); |
||||
|
}; |
||||
@ -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( |
||||
|
<TraceRoute |
||||
|
from={{ user: { longName: "Source Node" } } as any} |
||||
|
to={{ user: { longName: "Destination Node" } } as any} |
||||
|
route={[1, 2]} |
||||
|
snrTowards={[10, 20, 30]} |
||||
|
/> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TraceRoute |
||||
|
from={{ user: { longName: "Source Node" } } as any} |
||||
|
to={{ user: { longName: "Destination Node" } } as any} |
||||
|
route={[1]} |
||||
|
snrTowards={[15, 25]} |
||||
|
routeBack={[3]} |
||||
|
snrBack={[35, 45]} |
||||
|
/> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TraceRoute |
||||
|
from={{ user: { longName: "Source" } } as any} |
||||
|
to={{ user: { longName: "Dest" } } as any} |
||||
|
route={[1]} |
||||
|
/> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); |
||||
|
}); |
||||
|
|
||||
|
it("renders hop hex if node is not found", () => { |
||||
|
render( |
||||
|
<TraceRoute |
||||
|
from={{ user: { longName: "Source" } } as any} |
||||
|
to={{ user: { longName: "Dest" } } as any} |
||||
|
route={[99]} |
||||
|
/> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
|
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue