Compare commits
443 Commits
pre-releas
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633b99d6b2 | ||
|
|
87159b4eee | ||
|
|
6d39ecc7b9 | ||
|
|
7738661b7c | ||
|
|
4689ebe3ce | ||
|
|
6cd8ce5102 | ||
|
|
fed65d9c8b | ||
|
|
8f225f4d28 | ||
|
|
11e820d1d0 | ||
|
|
95fc72173f | ||
|
|
03b5c639fb | ||
|
|
4d30558aca | ||
|
|
7f376186b4 | ||
|
|
0de24c41ed | ||
|
|
88c4f84edb | ||
|
|
bf9557040f | ||
|
|
6d9a44a0e3 | ||
|
|
35aabdc900 | ||
|
|
163502156d | ||
|
|
8baa5d84b9 | ||
|
|
c55fdbd982 | ||
|
|
8da38ab2e4 | ||
|
|
dddb781627 | ||
|
|
77b3a7ac85 | ||
|
|
626970865f | ||
|
|
c0308532a1 | ||
|
|
a378cce0be | ||
|
|
488fd61558 | ||
|
|
dcbfb08f26 | ||
|
|
a7a448cbcd | ||
|
|
1780c6fb2a | ||
|
|
2d54df7dba | ||
|
|
890674eea3 | ||
|
|
93a70dfd47 | ||
|
|
6ac8646323 | ||
|
|
a215da1ebe | ||
|
|
22dbfbcc09 | ||
|
|
6341d564d3 | ||
|
|
28cc7b9800 | ||
|
|
5a142e671d | ||
|
|
ba3d45584d | ||
|
|
f54c0dd836 | ||
|
|
a6427a9ed1 | ||
|
|
11058dbf3b | ||
|
|
d062c2f1ab | ||
|
|
1f109d161f | ||
|
|
f2d6daa9fc | ||
|
|
9634e1ce39 | ||
|
|
64055a5aeb | ||
|
|
ad366e6bab | ||
|
|
9399104914 | ||
|
|
f82bc660b0 | ||
|
|
ed13af2382 | ||
|
|
e4c2952e49 | ||
|
|
0830eb9971 | ||
|
|
be9b61ec0c | ||
|
|
be0fe08f2f | ||
|
|
3f8d3389d5 | ||
|
|
20af1b4d34 | ||
|
|
207061e9d8 | ||
|
|
6633fc9c55 | ||
|
|
52b80613f8 | ||
|
|
db2cb8cb42 | ||
|
|
c320d7d173 | ||
|
|
db50bb5c1b | ||
|
|
3240ac57f7 | ||
|
|
2008b09ca3 | ||
|
|
491f72b426 | ||
|
|
a6f46bd38a | ||
|
|
2cebb8eee2 | ||
|
|
33ad9f989c | ||
|
|
c590ab2ff5 | ||
|
|
9da949d27a | ||
|
|
f1a58f0434 | ||
|
|
0296b241e4 | ||
|
|
344ad48858 | ||
|
|
97f2abb582 | ||
|
|
eca5d780c1 | ||
|
|
844a6316f6 | ||
|
|
d39c5ed079 | ||
|
|
09bb0bc43a | ||
|
|
266e27bfe9 | ||
|
|
5b11131e08 | ||
|
|
d70b14b12b | ||
|
|
c115ac0749 | ||
|
|
d54a612e0b | ||
|
|
d379769672 | ||
|
|
b670ffe407 | ||
|
|
4ffbe03b22 | ||
|
|
6a438470cf | ||
|
|
4d0d1da691 | ||
|
|
39f26f475b | ||
|
|
35fed173af | ||
|
|
a8b0515949 | ||
|
|
bd9d599934 | ||
|
|
b40079cdc9 | ||
|
|
b1cf4ef645 | ||
|
|
237c7bec3e | ||
|
|
75596e7153 | ||
|
|
7968994090 | ||
|
|
7c84a582ba | ||
|
|
9ad6c049f2 | ||
|
|
04ecdd55fe | ||
|
|
dbad25814c | ||
|
|
5af5364668 | ||
|
|
d28b4ce6d9 | ||
|
|
f9066eced0 | ||
|
|
a60db5521d | ||
|
|
e1d4490d62 | ||
|
|
cc24605cdb | ||
|
|
2647604798 | ||
|
|
6e3d326abb | ||
|
|
fc713f55fe | ||
|
|
81a1e0e550 | ||
|
|
88587f358d | ||
|
|
99a66bfdc7 | ||
|
|
50eb2a827f | ||
|
|
db08542b39 | ||
|
|
cbcbafc4a3 | ||
|
|
7e66dc7cac | ||
|
|
62fa8df8d2 | ||
|
|
b888d8f4cf | ||
|
|
e224a4ebdf | ||
|
|
977b5647f6 | ||
|
|
5a62b67e79 | ||
|
|
f31ac24707 | ||
|
|
a5589e232b | ||
|
|
f65f750b7f | ||
|
|
fa85e83817 | ||
|
|
d978978677 | ||
|
|
593c08f3e0 | ||
|
|
801f8f38d2 | ||
|
|
8be849d982 | ||
|
|
8bfa58540b | ||
|
|
4a6eb0d3f8 | ||
|
|
5c6ba38655 | ||
|
|
5831967603 | ||
|
|
fe2360baf6 | ||
|
|
3db2ede9d6 | ||
|
|
25cd448d69 | ||
|
|
c6bb11ccc8 | ||
|
|
d8bf2f7d8e | ||
|
|
079e60677e | ||
|
|
379ac46ec8 | ||
|
|
f4417f984b | ||
|
|
9f8d88bb4e | ||
|
|
cf4c3c1376 | ||
|
|
c39ef5916f | ||
|
|
edee3571be | ||
|
|
d039f7fd79 | ||
|
|
34abbcb467 | ||
|
|
1d93e358ed | ||
|
|
b4ada0449c | ||
|
|
4490d178d0 | ||
|
|
c9536c9ffd | ||
|
|
14e9bd304a | ||
|
|
5df409475e | ||
|
|
7427623c6e | ||
|
|
16d04bb878 | ||
|
|
9eda22b5db | ||
|
|
ebf64b5bcb | ||
|
|
fb2a057c05 | ||
|
|
635d0673bf | ||
|
|
864f8075d9 | ||
|
|
92a84af454 | ||
|
|
1560d1e18c | ||
|
|
bbb8384d66 | ||
|
|
b23e197178 | ||
|
|
ff6515269b | ||
|
|
d6f2d3a73f | ||
|
|
9b598aa3d7 | ||
|
|
88e0c6deda | ||
|
|
1f3ff3dc03 | ||
|
|
9cb449aa31 | ||
|
|
3afbc9a693 | ||
|
|
4ce84549c7 | ||
|
|
ac550d3b44 | ||
|
|
c9dab8d83c | ||
|
|
5e18c8d256 | ||
|
|
349a2bf855 | ||
|
|
3928d378f5 | ||
|
|
36d4f22007 | ||
|
|
cbbe9be819 | ||
|
|
ede6523678 | ||
|
|
ce086ffa82 | ||
|
|
a1715ef686 | ||
|
|
2c8b206903 | ||
|
|
5938f91861 | ||
|
|
b959a59e7b | ||
|
|
39c1176311 | ||
|
|
a10230b412 | ||
|
|
c9416a31a2 | ||
|
|
f7fe56535d | ||
|
|
ec10b63d11 | ||
|
|
a5131352c7 | ||
|
|
9f0a794f22 | ||
|
|
63ecb12fcf | ||
|
|
5a4774e9f3 | ||
|
|
35be1bee59 | ||
|
|
a7a9ba0463 | ||
|
|
0b7bdda4bf | ||
|
|
2612997355 | ||
|
|
84ca90ae97 | ||
|
|
8c693f1956 | ||
|
|
764328593d | ||
|
|
e9b6c2495c | ||
|
|
f4bddefd33 | ||
|
|
033409351e | ||
|
|
a6a66a7672 | ||
|
|
1f55d08adf | ||
|
|
664fd1c2d0 | ||
|
|
c9572af445 | ||
|
|
98edd30360 | ||
|
|
ab311bfa86 | ||
|
|
f4d58a9ea9 | ||
|
|
e769143e58 | ||
|
|
3c37899fe4 | ||
|
|
30d36789c1 | ||
|
|
971541a516 | ||
|
|
ca9e7d5c73 | ||
|
|
61529675ec | ||
|
|
22887b4dd6 | ||
|
|
c4383f4bd2 | ||
|
|
0f0751e4d2 | ||
|
|
2a460dfdba | ||
|
|
2e42620d67 | ||
|
|
5cc24fd6ab | ||
|
|
1c59d0451a | ||
|
|
6d26996d65 | ||
|
|
5f2f929af8 | ||
|
|
e129b7b469 | ||
|
|
c6f70a7b77 | ||
|
|
f2a2e5ddf2 | ||
|
|
b7f92388c5 | ||
|
|
8105f89c55 | ||
|
|
467effa62e | ||
|
|
2627b9035d | ||
|
|
d9aff93993 | ||
|
|
1fc72aa7be | ||
|
|
b7067923c0 | ||
|
|
ed3ae2622e | ||
|
|
e3cc95cfd8 | ||
|
|
9456495a3d | ||
|
|
53fe300fe9 | ||
|
|
76aea1a038 | ||
|
|
921f9b21a2 | ||
|
|
d552dcd137 | ||
|
|
9505284e61 | ||
|
|
d53acf204c | ||
|
|
175e98a080 | ||
|
|
b8c1096568 | ||
|
|
430e0cbd46 | ||
|
|
c2a2e0ac19 | ||
|
|
5a1c207ffc | ||
|
|
1c7b466e64 | ||
|
|
30158ca5c5 | ||
|
|
e896555694 | ||
|
|
87ddaad966 | ||
|
|
4736fa6b50 | ||
|
|
44b8dd308a | ||
|
|
a4e21ed343 | ||
|
|
48eb931c37 | ||
|
|
794d214636 | ||
|
|
fb1b4c6cc5 | ||
|
|
76de3e0e02 | ||
|
|
3f49dc2595 | ||
|
|
28c5fd64fe | ||
|
|
9d9c46f732 | ||
|
|
358f8a94d0 | ||
|
|
0c8901b5b2 | ||
|
|
4abc78fff3 | ||
|
|
095f1fde27 | ||
|
|
c2c7510dc4 | ||
|
|
1c70fb8606 | ||
|
|
75d6817012 | ||
|
|
3d3b59686c | ||
|
|
963deeca75 | ||
|
|
991405c7aa | ||
|
|
ce66e55196 | ||
|
|
7142f0f8d5 | ||
|
|
8ac714a5b5 | ||
|
|
97d206a9d4 | ||
|
|
fad6f72dd1 | ||
|
|
3f09e6de93 | ||
|
|
547b86f98e | ||
|
|
2bdfbedeea | ||
|
|
68da810a85 | ||
|
|
d9ad044ecd | ||
|
|
5b7b770aee | ||
|
|
b3cde1bcd7 | ||
|
|
9eeed9630b | ||
|
|
617b452da5 | ||
|
|
eb0ea4ea24 | ||
|
|
c7e2baea1b | ||
|
|
172e0c70c8 | ||
|
|
9b843f6483 | ||
|
|
e405a91ba8 | ||
|
|
cdc2554af6 | ||
|
|
2808b6f26a | ||
|
|
41ff10c653 | ||
|
|
4508428160 | ||
|
|
f0dd426055 | ||
|
|
d0ca24ae6f | ||
|
|
fe2b76eeb9 | ||
|
|
2fbf2a1173 | ||
|
|
c9377295db | ||
|
|
54c73a8c0d | ||
|
|
5d0f2e4403 | ||
|
|
47150c649f | ||
|
|
7930f44109 | ||
|
|
ec6906a5c3 | ||
|
|
891a1a9503 | ||
|
|
965e3247b0 | ||
|
|
f24041651c | ||
|
|
3f88373dd8 | ||
|
|
aa66e1f73c | ||
|
|
cf79f3b07e | ||
|
|
9493649a69 | ||
|
|
c8cd5b0eaa | ||
|
|
c16b3a3ce0 | ||
|
|
2967a74480 | ||
|
|
ea648ca887 | ||
|
|
8d35b57d19 | ||
|
|
be9a07bc4b | ||
|
|
7d37c6e728 | ||
|
|
42ccd953c0 | ||
|
|
578405d5d3 | ||
|
|
ebb32f0893 | ||
|
|
9cef18b82a | ||
|
|
88510a6ffe | ||
|
|
be6acc5ef2 | ||
|
|
4e35cf326e | ||
|
|
225d6055d4 | ||
|
|
7884991ac6 | ||
|
|
2d041ab6d0 | ||
|
|
c2cdc92ae9 | ||
|
|
6b10d35e1a | ||
|
|
790f93322e | ||
|
|
b50edb2762 | ||
|
|
88e06a1bea | ||
|
|
07d4204e86 | ||
|
|
0e78d0bd50 | ||
|
|
f3a3741216 | ||
|
|
74e33d09b1 | ||
|
|
ecd50148de | ||
|
|
08a28eeb68 | ||
|
|
cbabcd4782 | ||
|
|
63be65a487 | ||
|
|
1c8476df53 | ||
|
|
7cd03c6a52 | ||
|
|
db09711be5 | ||
|
|
94c6eea20b | ||
|
|
1ec3aa07d3 | ||
|
|
1087c68541 | ||
|
|
856556c12b | ||
|
|
fc24a389c0 | ||
|
|
5687485154 | ||
|
|
654d6c64c1 | ||
|
|
fed6b2a6da | ||
|
|
79806cc6a2 | ||
|
|
e78aa2df61 | ||
|
|
03173f6a37 | ||
|
|
3fa73894ed | ||
|
|
a147324913 | ||
|
|
0fc4211c13 | ||
|
|
6df0b287ef | ||
|
|
2f9af111c8 | ||
|
|
615045be9f | ||
|
|
6fc183ff15 | ||
|
|
9aafa681e8 | ||
|
|
f48f9ccd62 | ||
|
|
2bd80bb5b4 | ||
|
|
de597d2c28 | ||
|
|
94cca88e2e | ||
|
|
443c7fcd48 | ||
|
|
6138d9f8c8 | ||
|
|
37547b54e9 | ||
|
|
618e2f619b | ||
|
|
72fc3ea337 | ||
|
|
82f4784107 | ||
|
|
4f9fb9976d | ||
|
|
2cf7655562 | ||
|
|
79c5638e10 | ||
|
|
f53d53ea20 | ||
|
|
f2760a941b | ||
|
|
1e26eed861 | ||
|
|
2da4a44505 | ||
|
|
42068ad3d8 | ||
|
|
62f8c4509e | ||
|
|
d699764546 | ||
|
|
8549d56c21 | ||
|
|
4b532fc7f8 | ||
|
|
06d2c393ce | ||
|
|
cecdf9758b | ||
|
|
a2b9a33f6a | ||
|
|
02cb4f2584 | ||
|
|
56ac1d55f4 | ||
|
|
2a5acb8771 | ||
|
|
93e04e1b69 | ||
|
|
bd48b02ef3 | ||
|
|
8cfcd7b1af | ||
|
|
c0cb059f52 | ||
|
|
a2a9b37238 | ||
|
|
57d0d27bbb | ||
|
|
0e92dd9bea | ||
|
|
c16ebf3917 | ||
|
|
3d3a08a23f | ||
|
|
4d1227a942 | ||
|
|
a8ee273b24 | ||
|
|
3ee7a57480 | ||
|
|
2f2c777c56 | ||
|
|
2f36118e9d | ||
|
|
a6d161581f | ||
|
|
d05ea5a2cc | ||
|
|
471db94242 | ||
|
|
2654e4fbc9 | ||
|
|
f2aa5bfbee | ||
|
|
3b018b0c70 | ||
|
|
921db10d91 | ||
|
|
bf4f593e3a | ||
|
|
1e061a1e19 | ||
|
|
9b9f537e2c | ||
|
|
985cce0b0d | ||
|
|
3fe38eb506 | ||
|
|
51081d3052 | ||
|
|
c08f6d16bb | ||
|
|
62ad4c49f8 | ||
|
|
3b0a1e6108 | ||
|
|
c2f2205626 | ||
|
|
87c729d694 | ||
|
|
8e4f60edf3 | ||
|
|
8811eee9f5 | ||
|
|
2af93f1acd | ||
|
|
78a35544c7 | ||
|
|
3ad2d650b0 | ||
|
|
bf425a8ec7 | ||
|
|
a7d0d36086 | ||
|
|
fd9e327c85 | ||
|
|
8ed3ce8203 | ||
|
|
ebd5a3d3a6 | ||
|
|
1cdf18747d | ||
|
|
d8261a649b | ||
|
|
c4565d97b0 |
84
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -6,8 +6,20 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
# Bug Report
|
||||
Thanks for taking the time to fill out this bug report! The more information you provide, the faster we can diagnose and fix the issue.
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Before submitting
|
||||
description: Please confirm you've completed the following steps
|
||||
options:
|
||||
- label: I have searched existing issues to make sure this bug hasn't already been reported
|
||||
required: true
|
||||
- label: I have updated to the latest version of the software to verify the issue still exists
|
||||
required: true
|
||||
- label: I have cleared cache/cookies/storage or tried in a private/incognito window (if applicable)
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: hardware
|
||||
attributes:
|
||||
@@ -41,7 +53,6 @@ body:
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
@@ -54,7 +65,6 @@ body:
|
||||
- Serial
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: local
|
||||
attributes:
|
||||
@@ -66,7 +76,6 @@ body:
|
||||
- https://client.meshtastic.org
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -75,15 +84,50 @@ body:
|
||||
placeholder: x.x.x.yyyyyyy
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: body
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide details on what steps you performed for this to happen.
|
||||
label: Operating System
|
||||
description: What OS are you running? Include version if possible.
|
||||
placeholder: e.g., Windows 11, macOS 13.1, Android 13, iOS 16.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: What browser are you using? Include version if possible.
|
||||
placeholder: e.g., Chrome 108, Firefox 107, Safari 16.2
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what you expected to occur...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what occurred instead...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide clear steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
@@ -92,3 +136,21 @@ body:
|
||||
render: Shell
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for helping improve our project by reporting this bug!
|
||||
60
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -6,12 +6,60 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your request this will not gurantee that we will implement it, but it will be reviewed.
|
||||
|
||||
- type: textarea
|
||||
id: body
|
||||
Thanks for your request. While we can't guarantee implementation, all requests will be carefully reviewed.
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide details about your enhancement.
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting your feature request
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this feature hasn't already been requested
|
||||
required: true
|
||||
- label: I have checked the documentation to verify this feature doesn't already exist
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: What problem are you trying to solve? Describe the challenge or limitation you're facing.
|
||||
placeholder: I'm frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe your idea for solving the problem. What would you like to see implemented?
|
||||
placeholder: It would be great if...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Current Alternatives
|
||||
description: Are there any workarounds or alternative solutions you're currently using?
|
||||
placeholder: Currently, I'm working around this by...
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: importance
|
||||
attributes:
|
||||
label: Importance
|
||||
description: How important is this feature to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Important
|
||||
- Critical
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, mockups, or examples that might help us understand your request better.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this feature request!
|
||||
48
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
Thank you for your contribution to our project! Please fill out the following template to help reviewers understand your changes.
|
||||
-->
|
||||
|
||||
## Description
|
||||
<!--
|
||||
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
|
||||
-->
|
||||
|
||||
## Related Issues
|
||||
<!--
|
||||
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
|
||||
If there are no related issues, you can remove this section.
|
||||
-->
|
||||
|
||||
## Changes Made
|
||||
<!--
|
||||
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
|
||||
-->
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Testing Done
|
||||
<!--
|
||||
Describe how you tested these changes.
|
||||
-->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!--
|
||||
If your changes affect the UI, include screenshots or screencasts showing the before and after.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
<!--
|
||||
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
|
||||
-->
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Documentation has been updated or added
|
||||
- [ ] Tests have been added or updated
|
||||
- [ ] All CI checks pass
|
||||
- [ ] Dependent changes have been merged
|
||||
|
||||
## Additional Notes
|
||||
<!--
|
||||
Add any other context about the PR here.
|
||||
-->
|
||||
15
.github/workflows/ci.yml
vendored
@@ -13,14 +13,19 @@ jobs:
|
||||
build-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
version: latest
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
run: deno install
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
run: deno task build
|
||||
|
||||
66
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: 'Nightly Release'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 5 * * *" # Run every day at 5am UTC
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: deno install
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
- name: Build Package
|
||||
run: deno task build
|
||||
|
||||
- name: Package Output
|
||||
run: deno task package
|
||||
|
||||
- name: Archive compressed build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: dist/build.tar
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Buildah Build
|
||||
id: build-container
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./infra/Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: nightly ${{ github.sha }}
|
||||
oci: true
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
- name: Push To Registry
|
||||
id: push-to-registry
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-container.outputs.image }}
|
||||
tags: ${{ steps.build-container.outputs.tags }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Print image url
|
||||
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
||||
15
.github/workflows/pr.yml
vendored
@@ -9,19 +9,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
version: latest
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
run: deno install
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
run: deno task build
|
||||
|
||||
- name: Compress build
|
||||
run: pnpm package
|
||||
run: deno task package
|
||||
|
||||
- name: Archive compressed build
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
23
.github/workflows/release.yml
vendored
@@ -14,18 +14,29 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
version: latest
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
run: deno install
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
run: deno task build
|
||||
|
||||
- name: Package Output
|
||||
run: pnpm package
|
||||
run: deno task package
|
||||
|
||||
- name: Archive compressed build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: dist/build.tar
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -35,7 +46,7 @@ jobs:
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
./infra/Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: latest ${{ github.sha }}
|
||||
oci: true
|
||||
|
||||
4
.gitignore
vendored
@@ -2,4 +2,6 @@ dist
|
||||
node_modules
|
||||
stats.html
|
||||
.vercel
|
||||
dev-dist
|
||||
.vite
|
||||
dev-dist
|
||||
__screenshots__*
|
||||
2
.npmrc
@@ -1 +1 @@
|
||||
@buf:registry=https://buf.build/gen/npm/v1
|
||||
@jsr:registry=https://npm.jsr.io
|
||||
|
||||
9
.vscode/settings.json
vendored
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
"deno.enable": true,
|
||||
"deno.suggest.imports.autoDiscover": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM registry.access.redhat.com/ubi9/nginx-122:1-45
|
||||
|
||||
ADD dist .
|
||||
|
||||
CMD nginx -g "daemon off;"
|
||||
145
README.md
@@ -9,7 +9,8 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted or served from a node
|
||||
Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
||||
or served from a node
|
||||
|
||||
**[Hosted version](https://client.meshtastic.org)**
|
||||
|
||||
@@ -17,45 +18,149 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
||||
|
||||

|
||||
|
||||
## Progress Web App Support (PWA)
|
||||
|
||||
Meshtastic Web Client now includes Progressive Web App (PWA) functionality,
|
||||
allowing users to:
|
||||
|
||||
- Install the app on desktop and mobile devices
|
||||
- Access the interface offline
|
||||
- Receive updates automatically
|
||||
- Experience faster load times with caching
|
||||
|
||||
To install as a PWA:
|
||||
|
||||
- On desktop: Look for the install icon in your browser's address bar
|
||||
- On mobile: Use "Add to Home Screen" option in your browser menu
|
||||
|
||||
PWA functionality works with both the hosted version and self-hosted instances.
|
||||
|
||||
## Self-host
|
||||
|
||||
The client can be self hosted using the precompiled container images with an OCI compatible runtime such as [Docker](https://www.docker.com/) or [Podman](https://podman.io/).
|
||||
The base image used is [UBI9 Nginx 1.22](https://catalog.redhat.com/software/containers/ubi9/nginx-122/63f7653b9b0ca19f84f7e9a1)
|
||||
The client can be self hosted using the precompiled container images with an OCI
|
||||
compatible runtime such as [Docker](https://www.docker.com/) or
|
||||
[Podman](https://podman.io/). The base image used is
|
||||
[Nginx 1.27](https://hub.docker.com/_/nginx)
|
||||
|
||||
```bash
|
||||
# With Docker
|
||||
docker run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
|
||||
#With Podman
|
||||
podman run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
```
|
||||
|
||||
## Nightly releases
|
||||
|
||||
Our nightly releases provide the latest development builds with cutting-edge
|
||||
features and fixes. These builds are automatically generated from the latest
|
||||
main branch every night and are available for testing and early adoption.
|
||||
|
||||
```bash
|
||||
# With Docker
|
||||
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly
|
||||
#With Podman
|
||||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> - Nightly builds represent the latest development state and may contain
|
||||
> breaking changes
|
||||
> - These builds undergo automated testing but may be less stable than tagged
|
||||
> release versions
|
||||
> - Not recommended for production environments unless you are actively testing
|
||||
> new features
|
||||
> - No guarantee of backward compatibility between nightly builds
|
||||
|
||||
### Version Information
|
||||
|
||||
Each nightly build is tagged with:
|
||||
|
||||
- The nightly tag for the latest build
|
||||
- A specific SHA for build reproducibility
|
||||
|
||||
### Feedback
|
||||
|
||||
If you encounter any issues with nightly builds, please report them in our
|
||||
[issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps
|
||||
improve the stability of future releases
|
||||
|
||||
## Development & Building
|
||||
|
||||
### Building and Packaging
|
||||
|
||||
Build the project:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
GZip the output:
|
||||
|
||||
```bash
|
||||
pnpm package
|
||||
```
|
||||
You'll need to download the package manager used with this repo. You can install
|
||||
it by visiting [deno.com](https://deno.com/) and following the installation
|
||||
instructions listed on the home page.
|
||||
|
||||
### Development
|
||||
|
||||
Install the dependencies.
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
deno i
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### Building and Packaging
|
||||
|
||||
Build the project:
|
||||
|
||||
```bash
|
||||
deno task build
|
||||
```
|
||||
|
||||
GZip the output:
|
||||
|
||||
```bash
|
||||
deno task package
|
||||
```
|
||||
|
||||
### Why Deno?
|
||||
|
||||
Meshtastic Web uses Deno as its development platform for several compelling
|
||||
reasons:
|
||||
|
||||
- **Built-in Security**: Deno's security-first approach requires explicit
|
||||
permissions for file, network, and environment access, reducing vulnerability
|
||||
risks.
|
||||
- **TypeScript Support**: Native TypeScript support without additional
|
||||
configuration, enhancing code quality and developer experience.
|
||||
- **Modern JavaScript**: First-class support for ESM imports, top-level await,
|
||||
and other modern JavaScript features.
|
||||
- **Simplified Tooling**: Built-in formatter, linter, test runner, and bundler
|
||||
eliminate the need for multiple third-party tools.
|
||||
- **Reproducible Builds**: Lockfile ensures consistent builds across all
|
||||
environments.
|
||||
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
|
||||
between server and client environments.
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Here’s how the deployment flow works for pull
|
||||
requests:
|
||||
|
||||
- **Preview Deployments:**\
|
||||
Every pull request automatically generates a preview deployment on Vercel.
|
||||
This allows you and reviewers to easily preview changes before merging.
|
||||
|
||||
- **Staging Environment (`client-test`):**\
|
||||
Once your PR is merged, your changes will be available on our staging site:
|
||||
[client-test.meshtastic.org](https://client-test.meshtastic.org/).\
|
||||
This environment supports rapid feature iteration and testing without
|
||||
impacting the production site.
|
||||
|
||||
- **Production Releases:**\
|
||||
At regular intervals, stable and fully tested releases are promoted to our
|
||||
production site: [client.meshtastic.org](https://client.meshtastic.org/).\
|
||||
This is the primary interface used by the public to connect with their
|
||||
Meshtastic nodes.
|
||||
|
||||
Please review our
|
||||
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
|
||||
before submitting a pull request. We appreciate your help in making the project
|
||||
better!
|
||||
|
||||
27
biome.json
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["vercel.json"]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "master"
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
34
deno.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"imports": {
|
||||
"@app/": "./src/",
|
||||
"@pages/": "./src/pages/",
|
||||
"@components/": "./src/components/",
|
||||
"@core/": "./src/core/",
|
||||
"@layouts/": "./src/layouts/"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext",
|
||||
"deno.window",
|
||||
"deno.ns"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strictNullChecks": true,
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node",
|
||||
"@types/web-bluetooth",
|
||||
"@types/w3c-web-serial"
|
||||
],
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"unstable": [
|
||||
"sloppy-imports"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="system">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
@@ -18,7 +18,7 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
<meta name="description" content="Meshtastic Web App" />
|
||||
<meta name="description" content="Meshtastic Web Client" />
|
||||
<title>Meshtastic Web</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
2
infra/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
../dist/build.tar
|
||||
../dist/output
|
||||
15
infra/Containerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
RUN rm -r /usr/share/nginx/html \
|
||||
&& mkdir -p /usr/share/nginx/html \
|
||||
&& mkdir -p /etc/nginx/conf.d
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
ADD dist .
|
||||
|
||||
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
42
infra/default.conf
Normal file
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/xml+rss
|
||||
font/ttf
|
||||
font/otf
|
||||
image/svg+xml;
|
||||
}
|
||||
145
package.json
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "meshtastic-web",
|
||||
"version": "2.3.3-0",
|
||||
"version": "2.6.0-0",
|
||||
"type": "module",
|
||||
"description": "Meshtastic web client",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && pnpm check && vite build ",
|
||||
"check": "biome check .",
|
||||
"check:fix": "pnpm check --write",
|
||||
"preview": "vite preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)"
|
||||
"build": "vite build",
|
||||
"build:analyze": "BUNDLE_ANALYZE=true deno task build",
|
||||
"lint": "deno lint src/",
|
||||
"lint:fix": "deno lint --fix src/",
|
||||
"format": "deno fmt src/",
|
||||
"dev": "deno task dev:ui",
|
||||
"dev:ui": "deno run -A npm:vite dev",
|
||||
"test": "deno run -A npm:vitest",
|
||||
"preview": "deno run -A npm:vite preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -19,68 +23,87 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/meshtastic/web/issues"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "deno task lint:fix && deno task format"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"deno task lint:fix",
|
||||
"deno task format"
|
||||
]
|
||||
},
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0",
|
||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||
"@meshtastic/js": "2.3.7-1",
|
||||
"@noble/curves": "^1.5.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.1",
|
||||
"@turf/turf": "^6.5.0",
|
||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
|
||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
|
||||
"maplibre-gl": "4.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-map-gl": "7.1.7",
|
||||
"react-qrcode-logo": "^2.10.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timeago-react": "^3.0.6",
|
||||
"zustand": "4.5.2"
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.486.0",
|
||||
"maplibre-gl": "5.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-map-gl": "8.0.2",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"rfc4648": "^1.5.4",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.8.2",
|
||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240820152623-fac6975bbc78.1",
|
||||
"@types/chrome": "^0.0.263",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/w3c-web-serial": "^1.0.6",
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"gzipper": "^7.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tar": "^6.2.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.0.313",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.17",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/serviceworker": "^0.0.127",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"gzipper": "^8.2.1",
|
||||
"happy-dom": "^17.4.4",
|
||||
"postcss": "^8.5.3",
|
||||
"simple-git-hooks": "^2.12.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.4.3",
|
||||
"testing-library": "^0.0.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.4",
|
||||
"vitest": "^3.1.1",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
6543
pnpm-lock.yaml
generated
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
5
public/devices/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Copyright Notice
|
||||
Copyright © 2024 Meshtastic LLC. All Rights Reserved.
|
||||
|
||||
## In reference to the GNU GPLv3 License terms defined in Section 7e
|
||||
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC).
|
||||
1
public/devices/diy.svg
Normal file
|
After Width: | Height: | Size: 89 KiB |
1
public/devices/heltec-ht62-esp32c3-sx1262.svg
Normal file
|
After Width: | Height: | Size: 62 KiB |
1
public/devices/heltec-mesh-node-t114-case.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
1
public/devices/heltec-mesh-node-t114.svg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
1
public/devices/heltec-v3-case.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
1
public/devices/heltec-v3.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
1
public/devices/heltec-vision-master-e213.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="528.89 806.04 942.22 446.84"><defs><style>.cls-1{fill:#e8eae8;}.cls-2{fill:#dbdddb;}.cls-3{fill:#c6842a;}.cls-4,.cls-5,.cls-7,.cls-9{fill:none;stroke-miterlimit:10;}.cls-4,.cls-5{stroke:#050606;}.cls-4,.cls-9{stroke-width:2.44px;}.cls-5,.cls-7{stroke-width:1.22px;}.cls-6{fill:#b7b7b7;}.cls-7,.cls-9{stroke:#b7b7b7;}.cls-8{fill:#cbcccb;}.cls-10{fill:#434543;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M1469.9,826.68v390.94a19.43,19.43,0,0,1-19.43,19.43H549.53a19.43,19.43,0,0,1-19.43-19.43V826.68a19.42,19.42,0,0,1,19.43-19.42h900.94A19.42,19.42,0,0,1,1469.9,826.68Z"></path><path class="cls-2" d="M574.23,807.26v429.79h-24.7a19.43,19.43,0,0,1-19.43-19.43V826.68a19.42,19.42,0,0,1,19.43-19.42Z"></path><path class="cls-2" d="M1469.9,826.68v390.94a19.43,19.43,0,0,1-19.43,19.43h-37.56V807.26h37.56A19.42,19.42,0,0,1,1469.9,826.68Z"></path><path class="cls-3" d="M574.23,1129.8h-7.47a4.55,4.55,0,0,1-4.55-4.54V919.05a4.55,4.55,0,0,1,4.55-4.55h7.47"></path><rect class="cls-4" x="530.11" y="807.26" width="939.78" height="429.79" rx="19.42"></rect><line class="cls-5" x1="574.23" y1="807.26" x2="574.23" y2="1237.05"></line><path class="cls-5" d="M574.23,1129.8h-7.47a4.55,4.55,0,0,1-4.55-4.54V919.05a4.55,4.55,0,0,1,4.55-4.55h7.47"></path><rect class="cls-6" x="599.01" y="970.1" width="11.52" height="104.11"></rect><rect class="cls-5" x="599.01" y="970.1" width="11.52" height="104.11"></rect><path class="cls-2" d="M610.53,816.78V935.32l38.37,11.55a7.85,7.85,0,0,1,5.6,7.53V1107a7.87,7.87,0,0,1-7.87,7.87h-36.1v110.58H1406V816.78Zm775.41,384.75H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66h754.51Z"></path><path class="cls-1" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><path class="cls-7" d="M610.53,816.78V935.32l38.37,11.55a7.85,7.85,0,0,1,5.6,7.53V1107a7.87,7.87,0,0,1-7.87,7.87h-36.1v110.58H1406V816.78Zm775.41,384.75H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66h754.51Z"></path><path class="cls-7" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><path class="cls-7" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><line class="cls-7" x1="666.4" y1="1132.79" x2="666.4" y2="1201.53"></line><line class="cls-7" x1="663.66" y1="916.86" x2="663.66" y2="840.45"></line><circle class="cls-8" cx="644.99" cy="878.66" r="10.7"></circle><circle class="cls-8" cx="644.99" cy="1171.55" r="10.7"></circle><circle class="cls-9" cx="644.99" cy="878.66" r="10.7"></circle><circle class="cls-9" cx="644.99" cy="1171.55" r="10.7"></circle><path class="cls-10" d="M1225,1237.05l2.23,11.19a4.25,4.25,0,0,0,4.17,3.42H1249a4.26,4.26,0,0,0,4.18-3.42l2.22-11.19"></path><path class="cls-10" d="M1306.87,1237.05l2.22,11.19a4.26,4.26,0,0,0,4.18,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.22-11.19"></path><path class="cls-10" d="M1388.77,1237.05l2.23,11.19a4.24,4.24,0,0,0,4.17,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.23-11.19"></path><path class="cls-4" d="M1225,1237.05l2.23,11.19a4.25,4.25,0,0,0,4.17,3.42H1249a4.26,4.26,0,0,0,4.18-3.42l2.22-11.19"></path><path class="cls-4" d="M1306.87,1237.05l2.22,11.19a4.26,4.26,0,0,0,4.18,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.22-11.19"></path><path class="cls-4" d="M1388.77,1237.05l2.23,11.19a4.24,4.24,0,0,0,4.17,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.23-11.19"></path><line class="cls-4" x1="1412.9" y1="807.26" x2="1412.9" y2="1237.05"></line></g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
public/devices/heltec-vision-master-e290.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
public/devices/heltec-vision-master-t190.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="479.57 786.58 1040.84 433.17"><defs><style>.cls-1{fill:#cccccb;}.cls-2{fill:#2b2b2b;}.cls-3,.cls-6,.cls-7,.cls-8{fill:none;stroke-miterlimit:10;}.cls-3,.cls-6,.cls-7{stroke:#050606;}.cls-3,.cls-8{stroke-width:1.65px;}.cls-4{fill:#40403f;}.cls-5{fill:#ddd;}.cls-6{stroke-width:1.62px;}.cls-7{stroke-width:1.64px;}.cls-8{stroke:#fff;}.cls-9{fill:#353535;}.cls-10{fill:#c08c2d;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M595,923.44H519.6A10.46,10.46,0,0,1,509.14,913V802.7a15.28,15.28,0,0,1,15.28-15.28h979.89a15.29,15.29,0,0,1,15.29,15.29v400.93a15.3,15.3,0,0,1-15.29,15.29H524.42a15.28,15.28,0,0,1-15.28-15.28V1102.1a10.46,10.46,0,0,1,10.46-10.46H595"></path><rect class="cls-2" x="611.37" y="796.48" width="819.83" height="411.47"></rect><line class="cls-3" x1="1441.99" y1="787.41" x2="1441.99" y2="1218.92"></line><path class="cls-4" d="M620.91,851.7v302.78a1.87,1.87,0,0,1-1.87,1.87h-13.8a8.7,8.7,0,0,1-.89,0,10.23,10.23,0,0,1-9.35-10.2v-286a10.24,10.24,0,0,1,9.35-10.2,8.7,8.7,0,0,1,.89,0H619A1.87,1.87,0,0,1,620.91,851.7Z"></path><rect class="cls-5" x="480.4" y="942.42" width="114.6" height="127.58"></rect><rect class="cls-3" x="480.4" y="942.42" width="114.6" height="127.58"></rect><path class="cls-6" d="M595,923.44H519.6A10.46,10.46,0,0,1,509.14,913V802.7a15.28,15.28,0,0,1,15.28-15.28h979.89a15.29,15.29,0,0,1,15.29,15.29v400.93a15.3,15.3,0,0,1-15.29,15.29H524.42a15.28,15.28,0,0,1-15.28-15.28V1102.1a10.46,10.46,0,0,1,10.46-10.46H595"></path><path class="cls-2" d="M584.65,970.14H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3V973.11A3,3,0,0,1,584.65,970.14Z"></path><rect class="cls-2" x="560.58" y="976.5" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-2" d="M584.65,1026.63H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3v-18.49A3,3,0,0,1,584.65,1026.63Z"></path><rect class="cls-2" x="560.58" y="1028.91" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-3" d="M584.65,970.14H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3V973.11A3,3,0,0,1,584.65,970.14Z"></path><rect class="cls-3" x="560.58" y="976.5" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-3" d="M584.65,1026.63H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3v-18.49A3,3,0,0,1,584.65,1026.63Z"></path><rect class="cls-3" x="560.58" y="1028.91" width="9.04" height="16.58" rx="2.72"></rect><polyline class="cls-7" points="611.37 1156.35 611.37 1207.95 1431.2 1207.95 1431.2 796.48 611.37 796.48 611.37 849.83"></polyline><line class="cls-3" x1="611.37" y1="1207.95" x2="611.37" y2="1218.93"></line><line class="cls-3" x1="611.37" y1="796.48" x2="611.37" y2="787.42"></line><rect class="cls-8" x="560.58" y="1107.17" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="1107.17" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="1166.28" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="1166.28" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="804.46" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="804.46" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="863.57" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="863.57" width="18.37" height="41.67" rx="4.91"></rect><circle class="cls-8" cx="1476.63" cy="831.74" r="27.15"></circle><circle class="cls-8" cx="1476.63" cy="1173.49" r="27.15"></circle><rect class="cls-9" x="676.15" y="804.6" width="742.79" height="396.03"></rect><path class="cls-10" d="M604.35,849.87v306.44a10.23,10.23,0,0,1-9.35-10.2v-286A10.24,10.24,0,0,1,604.35,849.87Z"></path><path class="cls-3" d="M605.24,849.83H619a1.87,1.87,0,0,1,1.87,1.87v302.78a1.87,1.87,0,0,1-1.87,1.87h-13.8A10.24,10.24,0,0,1,595,1146.11v-286A10.24,10.24,0,0,1,605.24,849.83Z"></path><rect class="cls-6" x="676.15" y="804.6" width="742.79" height="396.03"></rect></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
public/devices/heltec-wireless-paper-V1_0.svg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
1
public/devices/heltec-wireless-paper.svg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
1
public/devices/heltec-wireless-tracker-V1-0.svg
Normal file
|
After Width: | Height: | Size: 83 KiB |
1
public/devices/heltec-wireless-tracker.svg
Normal file
|
After Width: | Height: | Size: 83 KiB |
1
public/devices/heltec-wsl-v3.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
1
public/devices/nano-g2-ultra.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
2956
public/devices/pico.svg
Normal file
|
After Width: | Height: | Size: 102 KiB |
1
public/devices/promicro.svg
Normal file
|
After Width: | Height: | Size: 71 KiB |
1
public/devices/rak-wismeshtap.svg
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
2339
public/devices/rak11310.svg
Normal file
|
After Width: | Height: | Size: 164 KiB |
1
public/devices/rak2560.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
3514
public/devices/rak4631.svg
Normal file
|
After Width: | Height: | Size: 128 KiB |
1
public/devices/rak4631_case.svg
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
1
public/devices/rpipicow.svg
Normal file
|
After Width: | Height: | Size: 76 KiB |
1
public/devices/seeed-sensecap-indicator.svg
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
1
public/devices/seeed-xiao-s3.svg
Normal file
|
After Width: | Height: | Size: 28 KiB |
1
public/devices/station-g2.svg
Normal file
|
After Width: | Height: | Size: 31 KiB |
1
public/devices/t-deck.svg
Normal file
|
After Width: | Height: | Size: 23 KiB |
1
public/devices/t-echo.svg
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
1
public/devices/t-watch-s3.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="733.42 451.82 573.87 931.48"><defs><style>.cls-1{fill:#8e8d8e;}.cls-2{fill:#383839;}.cls-3{fill:#cccccb;}.cls-4{fill:#222226;}.cls-5,.cls-6{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-5{stroke-width:1.87px;}.cls-6{stroke-width:3.77px;}.cls-7{fill:#4c4c4d;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M1277.27,847.59h4.35a8.09,8.09,0,0,1,8.09,8.08v138a8.09,8.09,0,0,1-8.09,8.09h-4.35"></path><path class="cls-1" d="M1277.27,732.73h18a10.14,10.14,0,0,1,10.14,10.14v43A10.14,10.14,0,0,1,1295.26,796h-18a0,0,0,0,1,0,0V732.73A0,0,0,0,1,1277.27,732.73Z"></path><path class="cls-2" d="M1256.49,1200.6h0a14.19,14.19,0,0,1-2.83,12.5c-8.13,9.86-19.94,18.58-46,30.75-19.15,9-28.65,16-38.35,29.6a93.15,93.15,0,0,0-8.7,14.61c-6.95,15.17-11.77,44.44-11.77,65.66v3.61a24.09,24.09,0,0,1-24.1,24.09H887.83a24.09,24.09,0,0,1-24.1-24.09v-3.61c0-21.22-4.82-50.49-11.77-65.66a93.15,93.15,0,0,0-8.7-14.61c-9.7-13.63-19.2-20.65-38.35-29.6-26.06-12.17-37.87-20.89-46-30.75a14.22,14.22,0,0,1-2.82-12.5h0"></path><path class="cls-2" d="M756.09,634.53h0a14.19,14.19,0,0,1,2.83-12.5c8.12-9.86,19.93-18.58,46-30.75,19.15-8.95,28.65-16,38.35-29.6a93.15,93.15,0,0,0,8.7-14.61c6.95-15.17,11.77-44.44,11.77-65.66V477.8a24.09,24.09,0,0,1,24.1-24.09h236.92a24.09,24.09,0,0,1,24.1,24.09v3.61c0,21.22,4.82,50.49,11.77,65.66a93.15,93.15,0,0,0,8.7,14.61c9.7,13.63,19.2,20.65,38.35,29.6,26,12.17,37.86,20.89,46,30.75a14.19,14.19,0,0,1,2.83,12.5h0"></path><rect class="cls-3" x="735.31" y="598.25" width="541.96" height="638.99" rx="96.44"></rect><path class="cls-2" d="M1247.38,694.68v446.11a66.63,66.63,0,0,1-66.54,66.56H831.75a66.62,66.62,0,0,1-66.56-66.56V694.68a66.63,66.63,0,0,1,66.56-66.55h349.09A66.64,66.64,0,0,1,1247.38,694.68Z"></path><rect class="cls-4" x="817.71" y="721.76" width="379.03" height="388.6"></rect><path class="cls-5" d="M1247.38,694.68v446.11a66.63,66.63,0,0,1-66.54,66.56H831.75a66.62,66.62,0,0,1-66.56-66.56V694.68a66.63,66.63,0,0,1,66.56-66.55h349.09A66.64,66.64,0,0,1,1247.38,694.68Z"></path><rect class="cls-6" x="735.31" y="598.25" width="541.96" height="638.99" rx="96.44"></rect><path class="cls-6" d="M1256.49,1200.6h0a14.19,14.19,0,0,1-2.83,12.5c-8.13,9.86-19.94,18.58-46,30.75-19.15,9-28.65,16-38.35,29.6a93.15,93.15,0,0,0-8.7,14.61c-6.95,15.17-11.77,44.44-11.77,65.66v3.61a24.09,24.09,0,0,1-24.1,24.09H887.83a24.09,24.09,0,0,1-24.1-24.09v-3.61c0-21.22-4.82-50.49-11.77-65.66a93.15,93.15,0,0,0-8.7-14.61c-9.7-13.63-19.2-20.65-38.35-29.6-26.06-12.17-37.87-20.89-46-30.75a14.22,14.22,0,0,1-2.82-12.5h0"></path><path class="cls-6" d="M756.09,634.53h0a14.19,14.19,0,0,1,2.83-12.5c8.12-9.86,19.93-18.58,46-30.75,19.15-8.95,28.65-16,38.35-29.6a93.15,93.15,0,0,0,8.7-14.61c6.95-15.17,11.77-44.44,11.77-65.66V477.8a24.09,24.09,0,0,1,24.1-24.09h236.92a24.09,24.09,0,0,1,24.1,24.09v3.61c0,21.22,4.82,50.49,11.77,65.66a93.15,93.15,0,0,0,8.7,14.61c9.7,13.63,19.2,20.65,38.35,29.6,26,12.17,37.86,20.89,46,30.75a14.19,14.19,0,0,1,2.83,12.5h0"></path><rect class="cls-5" x="817.71" y="721.76" width="379.03" height="388.6"></rect><path class="cls-6" d="M1277.27,847.59h4.35a8.09,8.09,0,0,1,8.09,8.08v138a8.09,8.09,0,0,1-8.09,8.09h-4.35"></path><path class="cls-6" d="M1277.27,732.73h18a10.14,10.14,0,0,1,10.14,10.14v43A10.14,10.14,0,0,1,1295.26,796h-18a0,0,0,0,1,0,0V732.73A0,0,0,0,1,1277.27,732.73Z"></path><circle class="cls-7" cx="1083.08" cy="1177.35" r="16.6"></circle><rect class="cls-2" x="1280.24" y="739.77" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="750.91" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="762.06" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="773.2" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="784.34" width="16.77" height="4.59" rx="2.29"></rect></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
1
public/devices/tbeam-s3-core.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
1
public/devices/tbeam.svg
Normal file
|
After Width: | Height: | Size: 112 KiB |
1
public/devices/tlora-c6.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
public/devices/tlora-t3s3-epaper.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
public/devices/tlora-t3s3-v1.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
public/devices/tlora-v2-1-1_6.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
public/devices/tlora-v2-1-1_8.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
public/devices/tracker-t1000-e.svg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
160
public/devices/unknown.svg
Normal file
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
class="svg-icon"
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
viewBox="0 0 909.87988 546.85529"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
width="909.87988"
|
||||
height="546.85529"
|
||||
sodipodi:docname="unknown.svg"
|
||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.57169944"
|
||||
inkscape:cx="291.23695"
|
||||
inkscape:cy="107.57401"
|
||||
inkscape:window-width="1472"
|
||||
inkscape:window-height="890"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="38"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_7" /><defs
|
||||
id="defs3"><style
|
||||
id="style1">.cls-1{fill:#383838;}.cls-2{fill:#9f9f9e;}.cls-3{fill:#cbcccb;}.cls-4{fill:#b7b7b7;}.cls-5{fill:#353535;}.cls-6{fill:#b1a368;}.cls-7{fill:#2c2d2d;}.cls-10,.cls-11,.cls-8,.cls-9{fill:none;stroke:#050606;}.cls-10,.cls-11,.cls-8{stroke-miterlimit:10;}.cls-8,.cls-9{stroke-width:2px;}.cls-9{stroke-linecap:round;stroke-linejoin:round;}.cls-10{stroke-width:2.04px;}.cls-11{stroke-width:1.99px;}.cls-12{fill:#c08c2d;}.cls-13{fill:#af7a2b;}</style></defs><g
|
||||
id="Layer_7"
|
||||
data-name="Layer 7"
|
||||
transform="translate(-646.6554,-758.05941)"><path
|
||||
class="cls-2"
|
||||
d="m 1545.1753,893.49468 h 4.69 a 5.67,5.67 0 0 1 5.67,5.67 v 84.64998 a 5.67,5.67 0 0 1 -5.67,5.67 h -4.69"
|
||||
id="path1-4" /><rect
|
||||
class="cls-3"
|
||||
x="647.6554"
|
||||
y="862.80469"
|
||||
width="897.52002"
|
||||
height="441.10999"
|
||||
rx="11.7"
|
||||
id="rect2" /><path
|
||||
class="cls-2"
|
||||
d="m 681.12532,862.80468 v 113.47998 a 3.67,3.67 0 0 0 3.67,3.67 h 41 a 2.35,2.35 0 0 1 2.35,2.35 V 1303.9147 H 1517.6053 V 862.80468 Z M 1492.6453,1278.9147 H 753.18532 V 972.01466 a 17.06,17.06 0 0 0 -17.06,-17.06 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 h 783.99998 z"
|
||||
id="path2-7" /><path
|
||||
class="cls-3"
|
||||
d="M 1492.6453,887.80468 V 1278.9147 H 753.18532 V 972.01466 a 17,17 0 0 0 -7.2,-13.92 v -70.28998 z"
|
||||
id="path3-7"
|
||||
style="fill:#ffffff" /><path
|
||||
class="cls-4"
|
||||
d="m 745.98532,887.80468 v 70.28998 a 17,17 0 0 0 -9.86,-3.14 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 z"
|
||||
id="path4" /><rect
|
||||
class="cls-2"
|
||||
x="672.10535"
|
||||
y="1011.4448"
|
||||
width="13.53"
|
||||
height="148.39999"
|
||||
id="rect4" /><path
|
||||
class="cls-6"
|
||||
d="m 1077.2923,853.76468 h 71.71 a 2.55,2.55 0 0 1 2.55,2.55 v 6.48 h -76.8 v -6.48 a 2.55,2.55 0 0 1 2.54,-2.55 z"
|
||||
id="path7" /><path
|
||||
class="cls-8"
|
||||
d="m 1082.9205,761.22647 h 60.8838 a 6.1958134,4.8451518 0 0 1 6.1958,4.84516 v 77.32638 h -73.2754 v -77.32638 a 6.1958134,4.8451518 0 0 1 6.1958,-4.84516 z"
|
||||
id="path39"
|
||||
style="fill:#b1a368" /><rect
|
||||
class="cls-8"
|
||||
x="1066.9833"
|
||||
y="778.19855"
|
||||
width="91.504646"
|
||||
height="55.957298"
|
||||
rx="5.5511622"
|
||||
id="rect39"
|
||||
style="fill:#b1a368" /><path
|
||||
class="cls-2"
|
||||
d="m 1158.4522,782.53954 v 47.24724 a 5.5153484,4.3130254 0 0 1 -5.5512,4.34102 h -80.3665 a 5.5511623,4.341032 0 0 1 -5.587,-4.34102 v -47.24724 a 5.5511623,4.341032 0 0 1 5.587,-4.34103 h 80.5098 a 5.5153484,4.3130254 0 0 1 5.4079,4.34103 z"
|
||||
id="path41-4"
|
||||
style="fill:none;stroke:#050606;stroke-width:3.16706;stroke-miterlimit:10" /><rect
|
||||
class="cls-6"
|
||||
x="1079.9424"
|
||||
y="843.73468"
|
||||
width="65.989998"
|
||||
height="10.03"
|
||||
id="rect8" /><path
|
||||
class="cls-8"
|
||||
d="M 1492.6453,887.80468 V 1278.9147 H 753.18532 V 972.01466 a 17.06,17.06 0 0 0 -17.06,-17.06 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 h 783.99998 m 25,-25 H 681.12532 v 113.47998 a 3.68,3.68 0 0 0 3.67,3.67 h 41 a 2.35,2.35 0 0 1 2.35,2.35 V 1303.9147 H 1517.6053 V 862.80468 Z"
|
||||
id="path10" /><line
|
||||
class="cls-8"
|
||||
x1="745.99536"
|
||||
y1="958.09467"
|
||||
x2="745.99536"
|
||||
y2="887.80469"
|
||||
id="line10" /><rect
|
||||
class="cls-8"
|
||||
x="672.10535"
|
||||
y="1011.4448"
|
||||
width="13.53"
|
||||
height="148.39999"
|
||||
id="rect11" /><path
|
||||
class="cls-8"
|
||||
d="m 1545.1753,893.49468 h 4.69 a 5.67,5.67 0 0 1 5.67,5.67 v 84.64998 a 5.67,5.67 0 0 1 -5.67,5.67 h -4.69"
|
||||
id="path14" /><path
|
||||
class="cls-10"
|
||||
d="m 1077.2923,853.76468 h 71.71 a 2.55,2.55 0 0 1 2.55,2.55 v 6.48 h -76.8 v -6.48 a 2.55,2.55 0 0 1 2.54,-2.55 z"
|
||||
id="path16" /><rect
|
||||
class="cls-11"
|
||||
x="1079.9424"
|
||||
y="843.73468"
|
||||
width="65.989998"
|
||||
height="10.03"
|
||||
id="rect17" /><path
|
||||
class="cls-2"
|
||||
d="m 725.27532,910.38466 a 14,14 0 1 0 14,14 13.95,13.95 0 0 0 -14,-14 z m 0,21.5 a 7.55,7.55 0 1 1 7.54,-7.55 7.55,7.55 0 0 1 -7.54,7.55 z"
|
||||
id="path19" /><circle
|
||||
class="cls-8"
|
||||
cx="725.27539"
|
||||
cy="924.33466"
|
||||
r="7.5500002"
|
||||
id="circle19" /><circle
|
||||
class="cls-8"
|
||||
cx="725.27539"
|
||||
cy="924.33466"
|
||||
r="13.95"
|
||||
id="circle20" /><path
|
||||
d="m 445.36309,440.05365 c 0,11.52004 10.38375,20.85861 23.19309,20.85861 12.80937,0 23.19311,-9.33857 23.19311,-20.85861 0,-11.52005 -10.38374,-20.85861 -23.19311,-20.85861 -12.80934,0 -23.19309,9.33856 -23.19309,20.85861 z"
|
||||
fill="#ccc"
|
||||
id="path1"
|
||||
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.458227"
|
||||
transform="translate(646.6554,758.05941)" /><path
|
||||
d="m 469.40305,538.40107 c -119.83415,0 -217.31582,-93.40624 -217.31582,-208.23067 0,-114.82425 97.48167,-208.23058 217.31582,-208.23058 119.83417,0 217.31585,93.40633 217.31585,208.23058 0,114.82443 -97.48168,208.23067 -217.31585,208.23067 z m 0,-386.58065 c -102.63515,0 -186.13149,80.00572 -186.13149,178.34998 0,98.32948 83.49634,178.34997 186.13149,178.34997 102.61966,0 186.13151,-80.01997 186.13151,-178.34997 0,-98.34426 -83.51185,-178.34998 -186.13151,-178.34998 z"
|
||||
fill="#ccc"
|
||||
id="path2"
|
||||
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.474832"
|
||||
transform="translate(646.6554,758.05941)" /><path
|
||||
d="m 468.55618,391.96713 c -8.53552,0 -15.46205,-6.22977 -15.46205,-13.90533 v -23.51468 c 0,-22.75028 19.32709,-40.13201 36.39722,-55.47009 12.50833,-11.26363 25.45056,-22.88885 25.45056,-32.16398 0,-23.18095 -20.81195,-42.03718 -46.38573,-42.03718 -26.0067,0 -46.38619,18.0497 -46.38619,41.09158 0,7.67594 -6.92654,13.90533 -15.46208,13.90533 -8.53554,0 -15.46207,-6.22977 -15.46207,-13.9058 0,-37.99002 34.68046,-68.90262 77.31034,-68.90262 42.62989,0 77.31034,31.32967 77.31034,69.84869 0,20.81694 -17.54944,36.5856 -34.51132,51.84064 -13.452,12.07016 -27.33645,24.55758 -27.33645,35.77907 v 23.51468 c 0,7.6764 -6.92702,13.91969 -15.46257,13.91969 z"
|
||||
fill="#ccc"
|
||||
id="path3"
|
||||
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.458227;stroke:#000000;stroke-opacity:1"
|
||||
transform="translate(646.6554,758.05941)" /><rect
|
||||
class="cls-8"
|
||||
x="647.6554"
|
||||
y="862.80469"
|
||||
width="897.52002"
|
||||
height="441.10999"
|
||||
rx="11.7"
|
||||
id="rect28" /></g><path
|
||||
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
|
||||
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
|
||||
id="path11" /><path
|
||||
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
|
||||
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
|
||||
id="path12" /><path
|
||||
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
|
||||
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
|
||||
id="path13" /></svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
1
public/devices/wio-tracker-wm1110.svg
Normal file
|
After Width: | Height: | Size: 91 KiB |
1
public/devices/wm1110_dev_kit.svg
Normal file
|
After Width: | Height: | Size: 120 KiB |
1
public/images/chirpy.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
76
src/App.tsx
@@ -1,16 +1,20 @@
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.js";
|
||||
import { PageRouter } from "@app/PageRouter.js";
|
||||
import { CommandPalette } from "@components/CommandPalette.js";
|
||||
import { DeviceSelector } from "@components/DeviceSelector.js";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.js";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
|
||||
import { Toaster } from "@components/Toaster.js";
|
||||
import Footer from "@components/UI/Footer.js";
|
||||
import { ThemeController } from "@components/generic/ThemeController.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
import { Dashboard } from "@pages/Dashboard/index.js";
|
||||
import { MapProvider } from "react-map-gl";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
import { PageRouter } from "@app/PageRouter.tsx";
|
||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||
import { Toaster } from "@components/Toaster.tsx";
|
||||
import Footer from "@components/UI/Footer.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
||||
import type { JSX } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
import { MapProvider } from "react-map-gl/maplibre";
|
||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
const { getDevice } = useDeviceStore();
|
||||
@@ -20,7 +24,7 @@ export const App = (): JSX.Element => {
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
return (
|
||||
<ThemeController>
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<NewDeviceDialog
|
||||
open={connectDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -28,30 +32,30 @@ export const App = (): JSX.Element => {
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
<MapProvider>
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-backgroundPrimary text-textPrimary">
|
||||
<div className="flex flex-grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex flex-grow flex-col">
|
||||
{device ? (
|
||||
<div className="flex h-screen">
|
||||
<DialogManager />
|
||||
<CommandPalette />
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
||||
<div className="flex grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex grow flex-col">
|
||||
{device ? (
|
||||
<div className="flex h-screen w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Dashboard />
|
||||
<div className="flex flex-grow" />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MapProvider>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</MapProvider>
|
||||
</ThemeController>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeviceContext } from "@core/stores/deviceStore.js";
|
||||
import type { Device } from "@core/stores/deviceStore.js";
|
||||
import { DeviceContext } from "@core/stores/deviceStore.ts";
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface DeviceWrapperProps {
|
||||
@@ -7,10 +7,7 @@ export interface DeviceWrapperProps {
|
||||
device?: Device;
|
||||
}
|
||||
|
||||
export const DeviceWrapper = ({
|
||||
children,
|
||||
device,
|
||||
}: DeviceWrapperProps): JSX.Element => {
|
||||
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
|
||||
return (
|
||||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { ChannelsPage } from "@pages/Channels.js";
|
||||
import { ConfigPage } from "@pages/Config/index.js";
|
||||
import { MapPage } from "@pages/Map.js";
|
||||
import { MessagesPage } from "@pages/Messages.js";
|
||||
import { NodesPage } from "@pages/Nodes.js";
|
||||
import MapPage from "@app/pages/Map/index.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import ChannelsPage from "@pages/Channels.tsx";
|
||||
import ConfigPage from "@pages/Config/index.tsx";
|
||||
import MessagesPage from "@pages/Messages.tsx";
|
||||
import NodesPage from "@pages/Nodes.tsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
|
||||
export const PageRouter = (): JSX.Element => {
|
||||
export const ErrorBoundaryWrapper = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const PageRouter = () => {
|
||||
const { activePage } = useDevice();
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
{activePage === "messages" && <MessagesPage />}
|
||||
{activePage === "map" && <MapPage />}
|
||||
{activePage === "config" && <ConfigPage />}
|
||||
{activePage === "channels" && <ChannelsPage />}
|
||||
{activePage === "nodes" && <NodesPage />}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
43
src/__mocks__/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Mocks Directory
|
||||
|
||||
This directory contains mock implementations used by Vitest for testing.
|
||||
|
||||
## Structure
|
||||
|
||||
The directory structure mirrors the actual project structure to make mocking
|
||||
more intuitive:
|
||||
|
||||
```
|
||||
__mocks__/
|
||||
├── components/
|
||||
│ └── UI/
|
||||
│ ├── Dialog.tsx
|
||||
│ ├── Button.tsx
|
||||
│ ├── Checkbox.tsx
|
||||
│ └── ...
|
||||
├── core/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Auto-mocking
|
||||
|
||||
Vitest will automatically use the mock files in this directory when the
|
||||
corresponding module is imported in tests. For example, when a test imports
|
||||
`@components/UI/Dialog.tsx`, Vitest will use
|
||||
`__mocks__/components/UI/Dialog.tsx` instead.
|
||||
|
||||
## Creating New Mocks
|
||||
|
||||
To create a new mock:
|
||||
|
||||
1. Create a file in the same relative path as the original module
|
||||
2. Export the mocked functionality with the same names as the original
|
||||
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
|
||||
|
||||
## Mock Guidelines
|
||||
|
||||
- Keep mocks as simple as possible
|
||||
- Use `data-testid` attributes for easy querying in tests
|
||||
- Implement just enough functionality to test the component
|
||||
- Use TypeScript types to ensure compatibility with the original module
|
||||
20
src/__mocks__/components/UI/Button.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', () => ({
|
||||
Button: ({ children, name, disabled, onClick }: {
|
||||
children: React.ReactNode,
|
||||
variant: string,
|
||||
name: string,
|
||||
disabled?: boolean,
|
||||
onClick: () => void
|
||||
}) =>
|
||||
<button
|
||||
type="button"
|
||||
name={name}
|
||||
data-testid={`button-${name}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
}));
|
||||
6
src/__mocks__/components/UI/Checkbox.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@components/UI/Checkbox.tsx', () => ({
|
||||
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) =>
|
||||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
|
||||
}));
|
||||
43
src/__mocks__/components/UI/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Dialog = ({ children, open }: {
|
||||
children: React.ReactNode,
|
||||
open: boolean,
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||
|
||||
export const DialogContent = ({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||
|
||||
export const DialogHeader = ({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div data-testid="dialog-header">{children}</div>;
|
||||
|
||||
export const DialogTitle = ({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div data-testid="dialog-title">{children}</div>;
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
|
||||
|
||||
export const DialogFooter = ({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
6
src/__mocks__/components/UI/Label.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@components/UI/Label.tsx', () => ({
|
||||
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) =>
|
||||
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label>
|
||||
}));
|
||||
7
src/__mocks__/components/UI/Link.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Typography/Link.tsx', () => ({
|
||||
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) =>
|
||||
<a data-testid="link" href={href} className={className}>{children}</a>
|
||||
}));
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@components/UI/Command.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useCommandState } from "cmdk";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
@@ -17,13 +16,9 @@ import {
|
||||
EraserIcon,
|
||||
FactoryIcon,
|
||||
LayersIcon,
|
||||
LayoutIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
@@ -32,9 +27,13 @@ import {
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
XCircleIcon,
|
||||
Pin,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
|
||||
export interface Group {
|
||||
label: string;
|
||||
@@ -48,26 +47,21 @@ export interface Command {
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette = (): JSX.Element => {
|
||||
export const CommandPalette = () => {
|
||||
const {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setSelectedDevice,
|
||||
removeDevice,
|
||||
selectedDevice,
|
||||
darkMode,
|
||||
setDarkMode,
|
||||
setAccent,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
@@ -119,22 +113,22 @@ export const CommandPalette = (): JSX.Element => {
|
||||
{
|
||||
label: "Switch Node",
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => {
|
||||
return {
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Hashicon
|
||||
size={16}
|
||||
value={device.hardware.myNodeNum.toString()}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
};
|
||||
}),
|
||||
subItems: getDevices().map((device) => ({
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()
|
||||
}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "Connect New Node",
|
||||
@@ -169,15 +163,6 @@ export const CommandPalette = (): JSX.Element => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Disconnect",
|
||||
icon: XCircleIcon,
|
||||
action() {
|
||||
void connection?.disconnect();
|
||||
setSelectedDevice(0);
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Schedule Shutdown",
|
||||
icon: PowerIcon,
|
||||
@@ -192,6 +177,13 @@ export const CommandPalette = (): JSX.Element => {
|
||||
setDialogOpen("reboot", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reboot To OTA Mode",
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
setDialogOpen("rebootOTA", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Nodes",
|
||||
icon: TrashIcon,
|
||||
@@ -235,118 +227,14 @@ export const CommandPalette = (): JSX.Element => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Application",
|
||||
icon: LayoutIcon,
|
||||
commands: [
|
||||
{
|
||||
label: "Toggle Dark Mode",
|
||||
icon: MoonIcon,
|
||||
action() {
|
||||
setDarkMode(!darkMode);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Accent Color",
|
||||
icon: PaletteIcon,
|
||||
subItems: [
|
||||
{
|
||||
label: "Red",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#f25555]" : "bg-[#f28585]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("red");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Orange",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#e1720b]" : "bg-[#edb17a]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("orange");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Yellow",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#ac8c1a]" : "bg-[#e0cc87]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("yellow");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Green",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#27a341]" : "bg-[#8bc9c5]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("green");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Blue",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#2093fe]" : "bg-[#70afea]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("blue");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Purple",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#926bff]" : "bg-[#a09eef]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("purple");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Pink",
|
||||
icon: (
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
darkMode ? "bg-[#e454c4]" : "bg-[#dba0c7]"
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setAccent("pink");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
||||
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
@@ -355,20 +243,50 @@ export const CommandPalette = (): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
globalThis.addEventListener("keydown", handleKeydown);
|
||||
return () => globalThis.removeEventListener("keydown", handleKeydown);
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
>
|
||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{groups.map((group) => (
|
||||
<CommandGroup key={group.label} heading={group.label}>
|
||||
{sortedGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.label}
|
||||
heading={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{group.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePinnedItem(group.label)}
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
|
||||
)}
|
||||
aria-description={
|
||||
pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"
|
||||
}
|
||||
>
|
||||
<span
|
||||
data-label
|
||||
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
|
||||
/>
|
||||
<Pin
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
pinnedItems.includes(group.label)
|
||||
? "opacity-100 text-red-500"
|
||||
: "opacity-40 hover:opacity-70"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{group.commands.map((command) => (
|
||||
<div key={command.label}>
|
||||
<CommandItem
|
||||
@@ -1,31 +1,23 @@
|
||||
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.js";
|
||||
import { Separator } from "@components/UI/Seperator.js";
|
||||
import { Code } from "@components/UI/Typography/Code.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import {
|
||||
HomeIcon,
|
||||
LanguagesIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
|
||||
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Code } from "@components/UI/Typography/Code.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
|
||||
export const DeviceSelector = (): JSX.Element => {
|
||||
export const DeviceSelector = () => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
const {
|
||||
selectedDevice,
|
||||
setSelectedDevice,
|
||||
darkMode,
|
||||
setDarkMode,
|
||||
setCommandPaletteOpen,
|
||||
setConnectDialogOpen,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 bg-transparent pt-2 dark:border-slate-700">
|
||||
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
|
||||
<div className="flex flex-col overflow-y-hidden">
|
||||
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
|
||||
<DeviceSelectorButton
|
||||
@@ -44,9 +36,10 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
}}
|
||||
active={selectedDevice === device.id}
|
||||
>
|
||||
<Hashicon
|
||||
size={24}
|
||||
value={device.hardware.myNodeNum.toString()}
|
||||
<Avatar
|
||||
text={device.nodes
|
||||
.get(device.hardware.myNodeNum)
|
||||
?.user?.shortName.toString() ?? "UNK"}
|
||||
/>
|
||||
</DeviceSelectorButton>
|
||||
))}
|
||||
@@ -54,20 +47,14 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
className="transition-all duration-300 hover:text-accent"
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex w-20 flex-col items-center space-y-5 bg-transparent px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
>
|
||||
{darkMode ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
|
||||
<ThemeSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
@@ -75,11 +62,14 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
<button type="button" className="transition-all hover:text-accent">
|
||||
{/* TODO: This is being commented out until its fixed */}
|
||||
{
|
||||
/* <button type="button" className="transition-all hover:text-accent">
|
||||
<LanguagesIcon />
|
||||
</button>
|
||||
</button> */
|
||||
}
|
||||
<Separator />
|
||||
<Code>{process.env.COMMIT_HASH}</Code>
|
||||
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -5,18 +5,19 @@ export interface DeviceSelectorButtonProps {
|
||||
}
|
||||
|
||||
export const DeviceSelectorButton = ({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: DeviceSelectorButtonProps): JSX.Element => (
|
||||
}: DeviceSelectorButtonProps) => (
|
||||
<li
|
||||
className="aspect-w-1 aspect-h-1 relative w-full"
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick}
|
||||
>
|
||||
{active && (
|
||||
{
|
||||
/* {active && (
|
||||
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
)} */
|
||||
}
|
||||
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore.js";
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import { Label } from "@components/UI/Label.js";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export interface User {
|
||||
@@ -26,7 +28,7 @@ export interface DeviceNameDialogProps {
|
||||
export const DeviceNameDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeviceNameDialogProps): JSX.Element => {
|
||||
}: DeviceNameDialogProps) => {
|
||||
const { hardware, nodes, connection } = useDevice();
|
||||
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
@@ -40,7 +42,7 @@ export const DeviceNameDialog = ({
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
connection?.setOwner(
|
||||
new Protobuf.Mesh.User({
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...myNode?.user,
|
||||
...data,
|
||||
}),
|
||||
@@ -51,6 +53,7 @@ export const DeviceNameDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Device Name</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -60,9 +63,13 @@ export const DeviceNameDialog = ({
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>Long Name</Label>
|
||||
<Input {...register("longName")} />
|
||||
<Input className="dark:text-slte-900" {...register("longName")} />
|
||||
<Label>Short Name</Label>
|
||||
<Input maxLength={4} {...register("shortName")} />
|
||||
<Input
|
||||
className="dark:text-slte-900"
|
||||
maxLength={4}
|
||||
{...register("shortName")}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
|
||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.js";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.js";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.js";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||
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/NodeDetailsDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||
|
||||
export const DialogManager = (): JSX.Element => {
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
return (
|
||||
<>
|
||||
@@ -49,6 +54,36 @@ export const DialogManager = (): JSX.Element => {
|
||||
setDialogOpen("nodeRemoval", open);
|
||||
}}
|
||||
/>
|
||||
<PkiBackupDialog
|
||||
open={dialog.pkiBackup}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("pkiBackup", open);
|
||||
}}
|
||||
/>
|
||||
<NodeDetailsDialog
|
||||
open={dialog.nodeDetails}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("nodeDetails", open);
|
||||
}}
|
||||
/>
|
||||
<UnsafeRolesDialog
|
||||
open={dialog.unsafeRoles}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("unsafeRoles", open);
|
||||
}}
|
||||
/>
|
||||
<RefreshKeysDialog
|
||||
open={dialog.refreshKeys}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
<RebootOTADialog
|
||||
open={dialog.rebootOTA}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("rebootOTA", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Checkbox } from "@components/UI/Checkbox.js";
|
||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import { Label } from "@components/UI/Label.js";
|
||||
import { Switch } from "@components/UI/Switch.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -25,7 +27,7 @@ export interface ImportDialogProps {
|
||||
export const ImportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportDialogProps): JSX.Element => {
|
||||
}: ImportDialogProps) => {
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||
@@ -49,30 +51,32 @@ export const ImportDialog = ({
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(
|
||||
encodedChannelConfig.length +
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
"=",
|
||||
)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
setChannelSet(
|
||||
Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)),
|
||||
fromBinary(
|
||||
Protobuf.AppOnly.ChannelSetSchema,
|
||||
toByteArray(paddedString),
|
||||
),
|
||||
);
|
||||
setValidUrl(true);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setValidUrl(false);
|
||||
setChannelSet(undefined);
|
||||
}
|
||||
}, [importDialogInput]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.map((ch, index) => {
|
||||
channelSet?.settings.map((ch: unknown, index: number) => {
|
||||
connection?.setChannel(
|
||||
new Protobuf.Channel.Channel({
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index,
|
||||
role:
|
||||
index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
role: index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
}),
|
||||
);
|
||||
@@ -80,7 +84,7 @@ export const ImportDialog = ({
|
||||
|
||||
if (channelSet?.loraConfig) {
|
||||
connection?.setConfig(
|
||||
new Protobuf.Config.Config({
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "lora",
|
||||
value: channelSet.loraConfig,
|
||||
@@ -93,6 +97,7 @@ export const ImportDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Channel Set</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -104,6 +109,7 @@ export const ImportDialog = ({
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
className="dark:text-slate-900"
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
@@ -114,27 +120,31 @@ export const ImportDialog = ({
|
||||
<div className="w-36">
|
||||
<Label>Use Preset?</Label>
|
||||
<Switch
|
||||
disabled={true}
|
||||
disabled
|
||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
||||
/>
|
||||
</div>
|
||||
{/* <Select
|
||||
{
|
||||
/* <Select
|
||||
label="Modem Preset"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.modemPreset}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
|
||||
</Select> */}
|
||||
</Select> */
|
||||
}
|
||||
</div>
|
||||
{/* <Select
|
||||
{
|
||||
/* <Select
|
||||
label="Region"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.region}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
|
||||
</Select> */}
|
||||
</Select> */
|
||||
}
|
||||
|
||||
<span className="text-md block font-medium text-textPrimary">
|
||||
<span className="text-md block font-medium text-text-primary">
|
||||
Channels:
|
||||
</span>
|
||||
<div className="flex w-40 flex-col gap-1">
|
||||
|
||||
62
src/components/Dialog/LocationResponseDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
|
||||
export interface LocationResponseDialogProps {
|
||||
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const LocationResponseDialog = ({
|
||||
location,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LocationResponseDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const from = nodes.get(location?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName = from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
|
||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{location?.data.latitudeI / 1e7},{" "}
|
||||
{location?.data.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
<p>Altitude: {location?.data.altitude}m</p>
|
||||
</span>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,27 @@
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.js";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.js";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.js";
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "../../core/hooks/useBrowserFeatureDetection.ts";
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.js";
|
||||
import { Link } from "@components/UI/Typography/Link.js";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.js";
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "../UI/Typography/Link.tsx";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
|
||||
export interface TabElementProps {
|
||||
closeDialog: () => void;
|
||||
@@ -23,93 +30,135 @@ export interface TabElementProps {
|
||||
export interface TabManifest {
|
||||
label: string;
|
||||
element: React.FC<TabElementProps>;
|
||||
disabled: boolean;
|
||||
disabledMessage: string;
|
||||
disabledLink?: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
element: HTTP,
|
||||
disabled: false,
|
||||
disabledMessage: "Unsuported connection method",
|
||||
},
|
||||
{
|
||||
label: "Bluetooth",
|
||||
element: BLE,
|
||||
disabled: !navigator.bluetooth,
|
||||
disabledMessage:
|
||||
"Web Bluetooth is currently only supported by Chromium-based browsers",
|
||||
disabledLink:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
},
|
||||
{
|
||||
label: "Serial",
|
||||
element: Serial,
|
||||
disabled: !navigator.serial,
|
||||
disabledMessage:
|
||||
"WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
},
|
||||
];
|
||||
export interface NewDeviceProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
}
|
||||
|
||||
const links: { [key: string]: string } = {
|
||||
"Web Bluetooth":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
"Web Serial":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
"Secure Context":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
};
|
||||
|
||||
const listFormatter = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "disjunction",
|
||||
});
|
||||
|
||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) return null;
|
||||
|
||||
const browserFeatures = missingFeatures.filter(
|
||||
(feature) => feature !== "Secure Context",
|
||||
);
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const formatFeatureList = (features: string[]) => {
|
||||
const parts = listFormatter.formatToParts(features);
|
||||
return parts.map((part) => {
|
||||
if (part.type === "element") {
|
||||
return (
|
||||
<Link key={part.value} href={links[part.value]}>
|
||||
{part.value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <Fragment key={part.value}>{part.value}</Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-white">
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This connection type requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a
|
||||
supported browser, like Chrome or Edge.
|
||||
</>
|
||||
)}
|
||||
{needsSecureContext && (
|
||||
<>
|
||||
{browserFeatures.length > 0 && " Additionally, it"}
|
||||
{browserFeatures.length === 0 && "This application"} requires a
|
||||
{" "}
|
||||
<Link href={links["Secure Context"]}>secure context</Link>.
|
||||
Please connect using HTTPS or localhost.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Subtle>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NewDeviceProps): JSX.Element => {
|
||||
}: NewDeviceProps) => {
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
element: HTTP,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
label: "Bluetooth",
|
||||
element: BLE,
|
||||
isDisabled: unsupported.includes("Web Bluetooth") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
label: "Serial",
|
||||
element: Serial,
|
||||
isDisabled: unsupported.includes("Web Serial") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect New Device</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="HTTP">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
disabled={tab.disabled}
|
||||
>
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
{tab.disabled ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{tab.disabledMessage}
|
||||
</p>
|
||||
) : (
|
||||
<fieldset disabled={tab.isDisabled}>
|
||||
{tab.isDisabled
|
||||
? <ErrorMessage missingFeatures={unsupported} />
|
||||
: null}
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{(!navigator.bluetooth || !navigator.serial) && (
|
||||
<>
|
||||
<Subtle>
|
||||
Web Bluetooth and Web Serial are currently only supported by
|
||||
Chromium-based browsers.
|
||||
</Subtle>
|
||||
<Subtle>
|
||||
Read more:
|
||||
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility">
|
||||
Web Bluetooth
|
||||
</Link>
|
||||
|
||||
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility">
|
||||
Web Serial
|
||||
</Link>
|
||||
</Subtle>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
177
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
117
src/components/Dialog/NodeOptionsDialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { toast } from "../../core/hooks/useToast.ts";
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const NodeOptionsDialog = ({
|
||||
node,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeOptionsDialogProps) => {
|
||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
||||
const {
|
||||
setNodeNumToBeRemoved,
|
||||
setNodeNumDetails,
|
||||
setChatType,
|
||||
setActiveChat,
|
||||
} = useAppStore();
|
||||
const longName = node?.user?.longName ??
|
||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
||||
const shortName = node?.user?.shortName ??
|
||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Requesting position, please wait...",
|
||||
});
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: "Position request sent.",
|
||||
})
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
})
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div>
|
||||
<Button onClick={handleDirectMessage}>Direct Message</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleRequestPosition}>Request Position</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleTraceroute}>Trace Route</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNodeNumDetails(node.num);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}}
|
||||
>
|
||||
More Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
136
src/components/Dialog/PKIBackupDialog.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export interface PkiBackupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PkiBackupDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PkiBackupDialogProps) => {
|
||||
const { config, setDialogOpen } = useDevice();
|
||||
const privateKey = config.security?.privateKey;
|
||||
const publicKey = config.security?.publicKey;
|
||||
|
||||
const decodeKeyData = React.useCallback(
|
||||
(key: Uint8Array<ArrayBufferLike>) => {
|
||||
if (!key) return "";
|
||||
return fromByteArray(key ?? new Uint8Array(0));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setDialogOpen("pkiBackup", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const renderPrintWindow = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
|
||||
const printWindow = globalThis.open("", "_blank");
|
||||
if (printWindow) {
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>=== MESHTASTIC KEYS ===</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
h1 { font-size: 18px; }
|
||||
p { font-size: 14px; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>=== MESHTASTIC KEYS ===</h1>
|
||||
<br>
|
||||
<h2>Public Key:</h2>
|
||||
<p>${decodeKeyData(publicKey)}</p>
|
||||
<h2>Private Key:</h2>
|
||||
<p>${decodeKeyData(privateKey)}</p>
|
||||
<br>
|
||||
<p>=== END OF KEYS ===</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
|
||||
const decodedPrivateKey = decodeKeyData(privateKey);
|
||||
const decodedPublicKey = decodeKeyData(publicKey);
|
||||
|
||||
const formattedContent = [
|
||||
"=== MESHTASTIC KEYS ===\n\n",
|
||||
"Private Key:\n",
|
||||
decodedPrivateKey,
|
||||
"\n\nPublic Key:\n",
|
||||
decodedPublicKey,
|
||||
"\n\n=== END OF KEYS ===",
|
||||
].join("");
|
||||
|
||||
const blob = new Blob([formattedContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "meshtastic_keys.txt";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup Keys</DialogTitle>
|
||||
<DialogDescription>
|
||||
Its important to backup your public and private keys and store your
|
||||
backup securely!
|
||||
</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">
|
||||
If you lose your keys, you will need to reset your device.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => createDownloadKeyFile()}
|
||||
className=""
|
||||
>
|
||||
<DownloadIcon size={20} className="mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => renderPrintWindow()}>
|
||||
<PrinterIcon size={20} className="mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
|
||||
export interface PkiRegenerateDialogProps {
|
||||
open: boolean;
|
||||
@@ -18,10 +19,11 @@ export const PkiRegenerateDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PkiRegenerateDialogProps): JSX.Element => {
|
||||
}: PkiRegenerateDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Checkbox } from "@components/UI/Checkbox.js";
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import { Label } from "@components/UI/Label.js";
|
||||
import { Protobuf, type Types } from "@meshtastic/js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { ClipboardIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -27,7 +29,7 @@ export const QRDialog = ({
|
||||
onOpenChange,
|
||||
loraConfig,
|
||||
channels,
|
||||
}: QRDialogProps): JSX.Element => {
|
||||
}: QRDialogProps) => {
|
||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||
@@ -39,13 +41,16 @@ export const QRDialog = ({
|
||||
.filter((ch) => selectedChannels.includes(ch.index))
|
||||
.map((channel) => channel.settings)
|
||||
.filter((ch): ch is Protobuf.Channel.ChannelSettings => !!ch);
|
||||
const encoded = new Protobuf.AppOnly.ChannelSet(
|
||||
new Protobuf.AppOnly.ChannelSet({
|
||||
const encoded = create(
|
||||
Protobuf.AppOnly.ChannelSetSchema,
|
||||
create(Protobuf.AppOnly.ChannelSetSchema, {
|
||||
loraConfig,
|
||||
settings: channelsToEncode,
|
||||
}),
|
||||
);
|
||||
const base64 = fromByteArray(encoded.toBinary())
|
||||
const base64 = fromByteArray(
|
||||
toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded),
|
||||
)
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
@@ -58,6 +63,7 @@ export const QRDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate QR Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -82,7 +88,9 @@ export const QRDialog = ({
|
||||
onCheckedChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index),
|
||||
selectedChannels.filter((c) =>
|
||||
c !== channel.index
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([
|
||||
@@ -100,22 +108,20 @@ export const QRDialog = ({
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(true)}
|
||||
>
|
||||
Add Channels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(false)}
|
||||
>
|
||||
Replace Channels
|
||||
@@ -126,7 +132,8 @@ export const QRDialog = ({
|
||||
<Label>Sharable URL</Label>
|
||||
<Input
|
||||
value={qrCodeUrl}
|
||||
disabled={true}
|
||||
disabled
|
||||
className="dark:text-slate-900"
|
||||
action={{
|
||||
icon: ClipboardIcon,
|
||||
onClick() {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface RebootDialogProps {
|
||||
export const RebootDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RebootDialogProps): JSX.Element => {
|
||||
}: RebootDialogProps) => {
|
||||
const { connection } = useDevice();
|
||||
|
||||
const [time, setTime] = useState<number>(5);
|
||||
@@ -27,6 +28,7 @@ export const RebootDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Reboot</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -36,6 +38,7 @@ export const RebootDialog = ({
|
||||
<div className="flex gap-2 p-4">
|
||||
<Input
|
||||
type="number"
|
||||
className="dark:text-slate-900"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
action={{
|
||||
|
||||
114
src/components/Dialog/RebootOTADialog.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Button.tsx');
|
||||
return {
|
||||
...actual,
|
||||
Button: (props: any) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Input.tsx');
|
||||
return {
|
||||
...actual,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Dialog.tsx', () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe('RebootOTADialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders dialog with default input value', () => {
|
||||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('schedules a reboot with delay and calls rebootOta', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: '3' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
|
||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(0);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers an instant reboot', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(5);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call reboot if connection is undefined', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
// simulate no connection
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// reset connection for other tests
|
||||
mockConnection = { rebootOta: rebootOtaMock };
|
||||
});
|
||||
|
||||
});
|
||||
104
src/components/Dialog/RebootOTADialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export interface RebootOTADialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
|
||||
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return
|
||||
};
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
|
||||
const parsed = Number(val);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setTime(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebootWithTimeout = async () => {
|
||||
if (!connection) return;
|
||||
setIsScheduled(true);
|
||||
|
||||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log("Rebooting...");
|
||||
resolve();
|
||||
}, delay * 1000);
|
||||
}).finally(() => {
|
||||
setIsScheduled(false);
|
||||
onOpenChange(false);
|
||||
setInputValue(DEFAULT_REBOOT_DELAY.toString());
|
||||
});
|
||||
connection.rebootOta(0);
|
||||
};
|
||||
|
||||
const handleInstantReboot = async () => {
|
||||
if (!connection) return;
|
||||
|
||||
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 p-2 items-center relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
className="dark:text-slate-900 appearance-none"
|
||||
value={inputValue}
|
||||
onChange={handleSetTime}
|
||||
placeholder="Enter delay (sec)"
|
||||
/>
|
||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
Reboot to OTA Mode Now
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
vi.mock("./useRefreshKeysDialog.ts", () => ({
|
||||
useRefreshKeysDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("RefreshKeysDialog Component", () => {
|
||||
let handleCloseDialogMock: Mock;
|
||||
let handleNodeRemoveMock: Mock;
|
||||
let onOpenChangeMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
handleCloseDialogMock = vi.fn();
|
||||
handleNodeRemoveMock = vi.fn();
|
||||
onOpenChangeMock = vi.fn();
|
||||
|
||||
(useRefreshKeysDialog as Mock).mockReturnValue({
|
||||
handleCloseDialog: handleCloseDialogMock,
|
||||
handleNodeRemove: handleNodeRemoveMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the dialog with correct content", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Request New Keys"));
|
||||
expect(handleNodeRemoveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Dismiss"));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenChange when dialog close button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render when open is false", () => {
|
||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2">
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keys Mismatch</DialogTitle>
|
||||
</DialogHeader>
|
||||
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">Accept New Keys</p>
|
||||
<p>
|
||||
This will remove the node from device and request new keys.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleNodeRemove}
|
||||
className=""
|
||||
>
|
||||
Request New Keys
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
className=""
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{/* </DialogDescription> */}
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
||||
}));
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: vi.fn(() => ({
|
||||
removeNode: vi.fn(),
|
||||
setDialogOpen: vi.fn(),
|
||||
getNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useRefreshKeysDialog Hook", () => {
|
||||
let removeNodeMock: Mock;
|
||||
let setDialogOpenMock: Mock;
|
||||
let getNodeErrorMock: Mock;
|
||||
let clearNodeErrorMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
removeNodeMock = vi.fn();
|
||||
setDialogOpenMock = vi.fn();
|
||||
getNodeErrorMock = vi.fn();
|
||||
clearNodeErrorMock = vi.fn();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
removeNode: removeNodeMock,
|
||||
setDialogOpen: setDialogOpenMock,
|
||||
getNodeError: getNodeErrorMock,
|
||||
clearNodeError: clearNodeErrorMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
|
||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||
getNodeErrorMock.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCloseDialog should close the dialog", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseDialog();
|
||||
});
|
||||
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
||||
const { activeChat } = useAppStore();
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
handleCloseDialog();;
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen('refreshKeys', false);
|
||||
}, [setDialogOpen])
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { useDevice } from "@app/core/stores/deviceStore.js";
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Label } from "@components/UI/Label.js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
|
||||
export interface RemoveNodeDialogProps {
|
||||
open: boolean;
|
||||
@@ -19,7 +20,7 @@ export interface RemoveNodeDialogProps {
|
||||
export const RemoveNodeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RemoveNodeDialogProps): JSX.Element => {
|
||||
}: RemoveNodeDialogProps) => {
|
||||
const { connection, nodes, removeNode } = useDevice();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
|
||||
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Node?</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface ShutdownDialogProps {
|
||||
export const ShutdownDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ShutdownDialogProps): JSX.Element => {
|
||||
}: ShutdownDialogProps) => {
|
||||
const { connection } = useDevice();
|
||||
|
||||
const [time, setTime] = useState<number>(5);
|
||||
@@ -27,6 +28,7 @@ export const ShutdownDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Shutdown</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -39,6 +41,7 @@ export const ShutdownDialog = ({
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
className="dark:text-slate-900"
|
||||
suffix="Minutes"
|
||||
/>
|
||||
<Button
|
||||
|
||||
57
src/components/Dialog/TracerouteResponseDialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
|
||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
||||
|
||||
export interface TracerouteResponseDialogProps {
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const TracerouteResponseDialog = ({
|
||||
traceroute,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = traceroute?.data.snrTowards ?? [];
|
||||
const snrBack = traceroute?.data.snrBack ?? [];
|
||||
const from = nodes.get(traceroute?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName = from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
const to = nodes.get(traceroute?.to ?? 0);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<TraceRoute
|
||||
route={route}
|
||||
routeBack={routeBack}
|
||||
from={from}
|
||||
to={to}
|
||||
snrTowards={snrTowards}
|
||||
snrBack={snrBack}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// deno-lint-ignore-file
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
|
||||
describe("UnsafeRolesDialog", () => {
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithDeviceContext = (ui: any) => {
|
||||
return render(
|
||||
<DeviceWrapper device={mockDevice}>
|
||||
{ui}
|
||||
</DeviceWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveTextContent('Device Role Documentation');
|
||||
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
|
||||
});
|
||||
|
||||
it("displays the correct links", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
|
||||
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
|
||||
|
||||
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/");
|
||||
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
|
||||
});
|
||||
|
||||
it("does not allow confirmation until checkbox is checked", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("emits the correct event when closing via close button", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
});
|
||||
|
||||
it("emits the correct event when dismissing", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
});
|
||||
|
||||
it("emits the correct event when confirming", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useState } from "react";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
export interface RouterRoleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
|
||||
const handleCloseDialog = (action: 'confirm' | 'dismiss') => {
|
||||
setDialogOpen('unsafeRoles', false);
|
||||
setConfirmState(false);
|
||||
eventBus.emit('dialog:unsafeRoles', { action });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col">
|
||||
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-md">
|
||||
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "}
|
||||
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role.
|
||||
</DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="routerRole"
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
>
|
||||
Yes, I know what I'm doing
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
name="dismiss"
|
||||
onClick={() => handleCloseDialog('dismiss')}> Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
name="confirm"
|
||||
disabled={!confirmState}
|
||||
onClick={() => handleCloseDialog('confirm')}> Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
||||
import { eventBus } from "@core/utils/eventBus";
|
||||
|
||||
vi.mock('@core/utils/eventBus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
useDevice: () => ({
|
||||
setDialogOpen: mockDevice.setDialogOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useUnsafeRolesDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderUnsafeRolesHook = () => {
|
||||
return renderHook(() => useUnsafeRolesDialog());
|
||||
};
|
||||
|
||||
describe('handleCloseDialog', () => {
|
||||
it('should call setDialogOpen with correct parameters when dialog is closed', () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
result.current.handleCloseDialog();
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleSelection', () => {
|
||||
it('should resolve with true for safe roles without opening dialog', async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = 'SAFE_ROLE';
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(safeRole);
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function));
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: 'confirm' });
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
});
|
||||
|
||||
it('should resolve with false when user dismisses the dialog', async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: 'dismiss' });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
expect(validationResult).toBe(false);
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
});
|
||||
|
||||
it('should clean up event listener after response', async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
|
||||
onHandler({ action: 'confirm' });
|
||||
await validationPromise;
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with all unsafe roles', async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
for (const unsafeRole of UNSAFE_ROLES) {
|
||||
mockDevice.setDialogOpen.mockClear();
|
||||
(eventBus.on as Mock).mockClear();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(unsafeRole);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: 'confirm' });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||