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