Browse Source

Merge branch 'master' into unread-counts

pull/497/head
Hunter Thornsberry 1 year ago
committed by GitHub
parent
commit
11b052e5bb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .gitignore
  2. 68
      README.md
  3. 279
      deno.lock
  4. 3
      package.json
  5. 9
      src/components/Dialog/DialogManager.tsx
  6. 12
      src/components/Dialog/NewDeviceDialog.tsx
  7. 190
      src/components/Dialog/NodeDetailsDialog.tsx
  8. 73
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  9. 177
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  10. 55
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  11. 61
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  12. 77
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  13. 28
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  14. 2
      src/components/Form/DynamicForm.tsx
  15. 64
      src/components/PageComponents/Channel.tsx
  16. 6
      src/components/PageComponents/Connect/Serial.tsx
  17. 5
      src/components/PageComponents/Messages/ChannelChat.tsx
  18. 122
      src/components/PageComponents/Messages/Message.tsx
  19. 126
      src/components/PageComponents/Messages/MessageItem.tsx
  20. 95
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  21. 78
      src/components/PageComponents/Messages/TraceRoute.tsx
  22. 2
      src/components/Sidebar.tsx
  23. 15
      src/components/ThemeSwitcher.tsx
  24. 11
      src/components/UI/Avatar.tsx
  25. 2
      src/components/UI/Button.tsx
  26. 12
      src/components/generic/Table/index.tsx
  27. 57
      src/core/stores/deviceStore.ts
  28. 34
      src/core/subscriptions.ts
  29. 8
      src/index.tsx
  30. 6
      src/pages/Messages.tsx
  31. 109
      src/pages/Nodes.tsx
  32. 9
      vite.config.ts

2
.gitignore

@ -2,6 +2,6 @@ dist
node_modules
stats.html
.vercel
.vite/deps
.vite
dev-dist
__screenshots__*

68
README.md

@ -139,46 +139,28 @@ reasons:
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
between server and client environments.
### Debugging
#### Debugging with React Scan
Meshtastic Web Client has included the library
[React Scan](https://github.com/aidenybai/react-scan) to help you identify and
resolve render performance issues during development.
React's comparison-by-reference approach to props makes it easy to inadvertently
cause unnecessary re-renders, especially with:
- Inline function callbacks (`onClick={() => handleClick()}`)
- Object literals (`style={{ color: "purple" }}`)
- Array literals (`items={[1, 2, 3]}`)
These are recreated on every render, causing child components to re-render even
when nothing has actually changed.
Unlike React DevTools, React Scan specifically focuses on performance
optimization by:
- Clearly distinguishing between necessary and unnecessary renders
- Providing render counts for components
- Highlighting slow-rendering components
- Offering a dedicated performance debugging experience
#### Usage
When experiencing slow renders, run:
```bash
deno task dev:scan
```
This will allow you to discover the following about your components and pages:
- Components with excessive re-renders
- Performance bottlenecks in the render tree
- Expensive hook operations
- Props that change reference on every render
Use these insights to apply targeted optimizations like `React.memo()`,
`useCallback()`, or `useMemo()` where they'll have the most impact.
### 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!

279
deno.lock

@ -56,7 +56,6 @@
"npm:react-hook-form@^7.54.2": "[email protected]",
"npm:[email protected]": "[email protected][email protected][email protected][email protected]",
"npm:react-qrcode-logo@3": "[email protected][email protected][email protected]",
"npm:react-scan@~0.2.8": "[email protected][email protected][email protected][email protected]",
"npm:react@19": "19.0.0",
"npm:rfc4648@^1.5.4": "1.5.4",
"npm:simple-git-hooks@^2.11.1": "2.11.1",
@ -899,168 +898,78 @@
"tough-cookie"
]
},
"@clack/[email protected]": {
"integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==",
"dependencies": [
"picocolors",
"sisteransi"
]
},
"@clack/[email protected]": {
"integrity": "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==",
"dependencies": [
"@clack/core",
"picocolors",
"sisteransi"
]
},
"@esbuild/[email protected]": {
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="
},
"@esbuild/[email protected]": {
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="
},
@ -1099,7 +1008,7 @@
"dependencies": [
"@inquirer/core",
"@inquirer/type",
"@types/node@22.13.8"
"@types/node"
]
},
"@inquirer/[email protected]_@[email protected]": {
@ -1107,7 +1016,7 @@
"dependencies": [
"@inquirer/figures",
"@inquirer/type",
"@types/node@22.13.8",
"@types/node",
"ansi-escapes",
"cli-width",
"mute-stream",
@ -1122,7 +1031,7 @@
"@inquirer/[email protected]_@[email protected]": {
"integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==",
"dependencies": [
"@types/node@22.13.8"
"@types/node"
]
},
"@isaacs/[email protected]": {
@ -1305,29 +1214,12 @@
"@open-draft/[email protected]": {
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="
},
"@pivanov/[email protected][email protected][email protected][email protected]": {
"integrity": "sha512-JQ/pXeG9/Yq3UuwH2Xp4F6bSAIDGzbxT0Vrg/82tMi3Yp+Ps9AYzjSDE+zfvBRqc7J11V6MMonUrWj4+2dYgrg==",
"dependencies": [
"react",
"react-dom"
]
},
"@pkgjs/[email protected]": {
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="
},
"@polka/[email protected]": {
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="
},
"@preact/[email protected]": {
"integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA=="
},
"@preact/[email protected][email protected]": {
"integrity": "sha512-naxcJgUJ6BTOROJ7C3QML7KvwKwCXQJYTc5L/b0eEsdYgPB6SxwoQ1vDGcS0Q7GVjAenVq/tXrybVdFShHYZWg==",
"dependencies": [
"@preact/signals-core",
"preact"
]
},
"@radix-ui/[email protected]": {
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
@ -3531,16 +3423,10 @@
"@types/pbf"
]
},
"@types/[email protected]": {
"integrity": "sha512-9RV2zST+0s3EhfrMZIhrz2bhuhBwxgkbHEwP2gtGWPjBzVQjifMzJ9exw7aDZhR1wbpj8zBrfp3bo8oJcGiUUw==",
"dependencies": [
"[email protected]"
]
},
"@types/[email protected]": {
"integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==",
"dependencies": [
"undici-types@6.20.0"
"undici-types"
]
},
"@types/[email protected]": {
@ -3848,9 +3734,6 @@
"[email protected]": {
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug=="
},
"[email protected]": {
"integrity": "sha512-LTCos3SmOJHrag0qF91tLUZMMw6wA+i15ESRBp71pvfNlTMYcxYoJHJ/pvFhd+29Wm5vfgVxBHV7kP5OKUUipg=="
},
"[email protected]": {
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
},
@ -4458,64 +4341,34 @@
"is-symbol"
]
},
"[email protected]": {
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dependencies": [
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]"
]
},
"[email protected]": {
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dependencies": [
"@esbuild/aix-ppc64@0.25.0",
"@esbuild/android-arm@0.25.0",
"@esbuild/android-arm64@0.25.0",
"@esbuild/android-x64@0.25.0",
"@esbuild/darwin-arm64@0.25.0",
"@esbuild/darwin-x64@0.25.0",
"@esbuild/freebsd-arm64@0.25.0",
"@esbuild/freebsd-x64@0.25.0",
"@esbuild/linux-arm@0.25.0",
"@esbuild/linux-arm64@0.25.0",
"@esbuild/linux-ia32@0.25.0",
"@esbuild/linux-loong64@0.25.0",
"@esbuild/linux-mips64el@0.25.0",
"@esbuild/linux-ppc64@0.25.0",
"@esbuild/linux-riscv64@0.25.0",
"@esbuild/linux-s390x@0.25.0",
"@esbuild/linux-x64@0.25.0",
"@esbuild/netbsd-arm64@0.25.0",
"@esbuild/netbsd-x64@0.25.0",
"@esbuild/openbsd-arm64@0.25.0",
"@esbuild/openbsd-x64@0.25.0",
"@esbuild/sunos-x64@0.25.0",
"@esbuild/win32-arm64@0.25.0",
"@esbuild/win32-ia32@0.25.0",
"@esbuild/win32-x64@0.25.0"
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
]
},
"[email protected]": {
@ -4701,12 +4554,6 @@
"get-intrinsic"
]
},
"[email protected]": {
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
"dependencies": [
"resolve-pkg-maps"
]
},
"[email protected]": {
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="
},
@ -5139,9 +4986,6 @@
"[email protected]": {
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
},
"[email protected]": {
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
},
"[email protected]": {
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="
},
@ -5340,9 +5184,6 @@
"[email protected]": {
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="
},
"[email protected]": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
"[email protected]": {
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="
},
@ -5607,9 +5448,6 @@
"[email protected]": {
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
},
"[email protected]": {
"integrity": "sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w=="
},
"[email protected]": {
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="
},
@ -5777,31 +5615,6 @@
"use-sidecar"
]
},
"[email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-+6Gvu9b0UMmzV0JkigA7Y2YcjQABiNrweP9l9j8nrutN5OAYLRe4JgfwiUohPFngMD+Y6I5N0kW+okXhvVLGUw==",
"dependencies": [
"@babel/core",
"@babel/generator",
"@babel/types",
"@clack/core",
"@clack/prompts",
"@pivanov/utils",
"@preact/signals",
"@rollup/[email protected][email protected]",
"@types/[email protected]",
"bippy",
"[email protected]",
"[email protected]",
"kleur",
"mri",
"playwright",
"preact",
"react",
"react-dom",
"tsx",
"unplugin"
]
},
"[email protected]_@[email protected][email protected]": {
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"dependencies": [
@ -5912,9 +5725,6 @@
"[email protected]": {
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"[email protected]": {
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="
},
"[email protected]": {
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"dependencies": [
@ -6153,9 +5963,6 @@
"totalist"
]
},
"[email protected]": {
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
},
"[email protected]": {
"integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg=="
},
@ -6516,14 +6323,6 @@
"[email protected]": {
"integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw=="
},
"[email protected]": {
"integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
"dependencies": [
"[email protected]",
"[email protected]",
"get-tsconfig"
]
},
"[email protected]": {
"integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="
},
@ -6601,9 +6400,6 @@
"which-boxed-primitive"
]
},
"[email protected]": {
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"[email protected]": {
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
},
@ -6644,13 +6440,6 @@
"[email protected]": {
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="
},
"[email protected]": {
"integrity": "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==",
"dependencies": [
"acorn",
"webpack-virtual-modules"
]
},
"[email protected]": {
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="
},
@ -6747,8 +6536,8 @@
"[email protected]_@[email protected]": {
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"dependencies": [
"@types/node@22.13.8",
"esbuild@0.25.0",
"@types/node",
"esbuild",
"[email protected]",
"postcss",
"[email protected]"
@ -6757,7 +6546,7 @@
"[email protected]_@[email protected][email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]___@[email protected][email protected]___@[email protected][email protected][email protected][email protected][email protected]_____@[email protected][email protected]_____@[email protected][email protected]____@[email protected][email protected][email protected][email protected]____@[email protected][email protected][email protected][email protected][email protected][email protected]___@[email protected][email protected]___@[email protected]__@[email protected][email protected][email protected]": {
"integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==",
"dependencies": [
"@types/node@22.13.8",
"@types/node",
"@vitest/browser",
"@vitest/expect",
"@vitest/[email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]__@[email protected][email protected]",
@ -6799,9 +6588,6 @@
"[email protected]": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"[email protected]": {
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="
},
"[email protected]": {
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="
},
@ -7151,7 +6937,6 @@
"npm:react-hook-form@^7.54.2",
"npm:[email protected]",
"npm:react-qrcode-logo@3",
"npm:react-scan@~0.2.8",
"npm:react@19",
"npm:rfc4648@^1.5.4",
"npm:simple-git-hooks@^2.11.1",

3
package.json

@ -12,7 +12,6 @@
"format": "deno fmt src/",
"dev": "deno task dev:ui",
"dev:ui": "deno run -A npm:vite dev",
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
"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/ ."
@ -72,10 +71,8 @@
"react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1",
"react-qrcode-logo": "^3.0.0",
"react-scan": "^0.2.8",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3"
},
"devDependencies": {

9
src/components/Dialog/DialogManager.tsx

@ -6,8 +6,9 @@ import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@ -70,6 +71,12 @@ export const DialogManager = () => {
setDialogOpen("unsafeRoles", open);
}}
/>
<RefreshKeysDialog
open={dialog.refreshKeys}
onOpenChange={(open) => {
setDialogOpen("refreshKeys", open);
}}
/>
</>
);
};

12
src/components/Dialog/NewDeviceDialog.tsx

@ -53,7 +53,7 @@ const links: { [key: string]: string } = {
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
@ -79,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
};
return (
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md">
<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" />
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<p className="text-sm">
<p className="text-sm text-white">
{browserFeatures.length > 0 && (
<>
This application requires{" "}
This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a
Chromium-based browser like Chrome or Edge.
supported browser, like Chrome or Edge.
</>
)}
{needsSecureContext && (

190
src/components/Dialog/NodeDetailsDialog.tsx

@ -1,190 +0,0 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "../generic/DeviceImage.tsx";
import { TimeAgo } from "../generic/TimeAgo.tsx";
import { Uptime } from "../generic/Uptime.tsx";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
return device
? (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[device.user?.hwModel ?? 0]}
/>
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Details:
</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]}
</p>
<p>
Last Heard: {device.lastHeard === 0
? (
"Never"
)
: <TimeAgo timestamp={device.lastHeard * 1000} />}
</p>
</div>
{device.position
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Position:
</p>
{device.position.latitudeI && device.position.longitudeI
? (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
}&mlon=${device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
)
: null}
{device.position.altitude
? <p>Altitude: {device.position.altitude}m</p>
: null}
</div>
)
: null}
{device.deviceMetrics
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{device.deviceMetrics.airUtilTx
? (
<p>
Air TX utilization:{" "}
{device.deviceMetrics.airUtilTx.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.channelUtilization
? (
<p>
Channel utilization:{" "}
{device.deviceMetrics.channelUtilization.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.batteryLevel
? (
<p>
Battery level:{" "}
{device.deviceMetrics.batteryLevel.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.voltage
? (
<p>
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V
</p>
)
: null}
{device.deviceMetrics.uptimeSeconds
? (
<p>
Uptime:{" "}
<Uptime
seconds={device.deviceMetrics.uptimeSeconds}
/>
</p>
)
: null}
</div>
)
: null}
{device
? (
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion
className="AccordionRoot"
type="single"
collapsible
>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
: null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
: null;
};

73
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -0,0 +1,73 @@
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
describe("NodeDetailsDialog", () => {
const mockDevice = {
num: 1234,
user: {
longName: "Test Node",
shortName: "TN",
hwModel: 1,
role: 1,
},
lastHeard: 1697500000,
position: {
latitudeI: 450000000,
longitudeI: -750000000,
altitude: 200,
},
deviceMetrics: {
airUtilTx: 50.123,
channelUtilization: 75.456,
batteryLevel: 88.789,
voltage: 4.2,
uptimeSeconds: 3600,
},
};
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
(useAppStore as unknown as Mock).mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<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

@ -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>
);
};

55
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -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();
});
});

61
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -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 >
);
};

77
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -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);
});
});

28
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -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
};
}

2
src/components/Form/DynamicForm.tsx

@ -124,7 +124,7 @@ export function DynamicForm<T extends FieldValues>({
})}
</div>
))}
{hasSubmitButton && <Button type="submit">Submit</Button>}
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
</form>
);
}

64
src/components/PageComponents/Channel.tsx

@ -34,12 +34,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...data.settings,
psk: toByteArray(pass),
moduleSettings: {
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
moduleSettings: {...data.settings.moduleSettings,
positionPrecision: data.settings.moduleSettings.positionPrecision,
},
},
});
@ -100,17 +96,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
positionEnabled:
channel?.settings?.moduleSettings?.positionPrecision !==
undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
moduleSettings: {...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
},
},
}}
@ -174,39 +162,30 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh",
},
{
type: "toggle",
name: "settings.positionEnabled",
label: "Allow Position Requests",
description: "Send position to channel",
},
{
type: "toggle",
name: "settings.preciseLocation",
label: "Precise Location",
description: "Send precise location to channel",
},
{
type: "select",
name: "settings.positionPrecision",
label: "Approximate Location",
name: "settings.moduleSettings.positionPrecision",
label: "Location",
description:
"If not sharing precise location, position shared on channel will be accurate within this distance",
"The precision of the location to share with the channel. Can be disabled.",
properties: {
enumValue: config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
"Do not share location": 0,
"Within 23 kilometers": 10,
"Within 12 kilometers": 11,
"Within 5.8 kilometers": 12,
"Within 2.9 kilometers": 13,
"Within 1.5 kilometers": 14,
"Within 700 meters": 15,
"Within 350 meters": 16,
"Within 200 meters": 17,
"Within 90 meters": 18,
"Within 50 meters": 19,
"Precise Location": 32,
}
: {
"Do not share location": 0,
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
@ -217,6 +196,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
"Precise Location": 32,
},
},
},

6
src/components/PageComponents/Connect/Serial.tsx

@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
setSerialPorts(await navigator?.serial.getPorts());
}, []);
navigator?.serial?.addEventListener("connect", (event) => {
console.log(event);
navigator?.serial?.addEventListener("connect", () => {
updateSerialPortList();
});
navigator?.serial?.addEventListener("disconnect", () => {
@ -47,8 +45,6 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
<div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => {
console.log(port);
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button

5
src/components/PageComponents/Messages/ChannelChat.tsx

@ -15,9 +15,10 @@ const EmptyState = () => (
);
export const ChannelChat = ({
messages,
messages = [],
}: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -57,7 +58,7 @@ export const ChannelChat = ({
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
>
<div className="flex flex-col justify-end min-h-full">
{messages.map((message, index) => (
{messages?.map((message, index) => (
<Message
key={message.id}
message={message}

122
src/components/PageComponents/Messages/Message.tsx

@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import {
Tooltip,
TooltipArrow,
@ -12,15 +13,13 @@ import {
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
const MESSAGE_STATES = {
ACK: "ack",
WAITING: "waiting",
FAILED: "failed",
} as const;
type MessageStateValue = {
state: string;
icon: LucideIcon;
displayText: string;
}
type MessageState = MessageWithState["state"];
@ -40,31 +39,36 @@ interface StatusIconProps {
className?: string;
}
const STATUS_TEXT_MAP: Record<MessageState, string> = {
[MESSAGE_STATES.ACK]: "Message delivered",
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
[MESSAGE_STATES.FAILED]: "Delivery failed",
};
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
[MESSAGE_STATES.ACK]: CheckCircle2,
[MESSAGE_STATES.WAITING]: CircleEllipsis,
[MESSAGE_STATES.FAILED]: AlertCircle,
const MESSAGE_STATES: Record<string, MessageStateValue> = {
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
};
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state];
const getMessageState = (state: MessageState): MessageStateValue => {
switch (state) {
case MESSAGE_STATES.ACK.state:
return MESSAGE_STATES.ACK;
case MESSAGE_STATES.WAITING.state:
return MESSAGE_STATES.WAITING;
case MESSAGE_STATES.FAILED.state:
return MESSAGE_STATES.FAILED;
default:
return MESSAGE_STATES.FAILED;
}
}
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{getStatusText(state)}
{getMessageState(state).displayText ?? "An unknown error occurred"};
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
);
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const isFailed = state === MESSAGE_STATES.FAILED;
const msgState = getMessageState(state);
const isFailed = msgState.state === 'failed'
const iconClass = cn(
className,
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0",
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
);
const Icon = STATUS_ICON_MAP[state];
const Icon = msgState.icon;
return (
<StatusTooltip state={state}>
<Icon
@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
);
};
const getMessageTextStyles = (state: MessageState) => {
const isAcknowledged = state === MESSAGE_STATES.ACK;
const isFailed = state === MESSAGE_STATES.FAILED;
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({
date,
className,
}: { date: Date; className?: string }) => (
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleDateString()}
@ -118,9 +110,9 @@ const TimeDisplay = ({
})}
</span>
</div>
);
));
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
[getDevices, message.from]
);
const messageUser = sender?.user;
const messageTextClass = getMessageTextStyles(message.state);
const getMessageTextStyles = (state: MessageState) => {
const msgState = getMessageState(state);
const isAcknowledged = msgState.state === 'ack'
const isFailed = msgState.state === 'failed'
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
return (
<div className="flex flex-col w-full px-4 justify-start">
<div
className={cn(
"flex flex-col flex-wrap items-start py-1",
isDeviceUser && "items-end",
isDeviceUser && "items-end"
)}
>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser
? (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
)
: null}
</div>
)}
</div>
<TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2">
@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
</div>
</div>
);
};
});

126
src/components/PageComponents/Messages/MessageItem.tsx

@ -0,0 +1,126 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import { Message, MessageState } from "@core/services/types.ts";
interface MessageProps {
lastMsgSameUser: boolean;
message: Message;
}
interface MessageStatus {
state: MessageState;
displayText: string;
icon: LucideIcon;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
};
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{status.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === "failed";
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
const Icon = status.icon;
return (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
</StatusTooltip>
);
};
const getMessageTextStyles = (status: MessageStatus) => {
const isAcknowledged = status.state === "ack";
const isFailed = status.state === "failed";
return cn(
"break-words overflow-hidden",
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
);
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
);
const messageUser = message?.from
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
: null;
const messageStatus = getMessageStatus(message.state);
const messageTextClass = getMessageTextStyles(messageStatus);
return (
<div className="flex flex-col w-full px-4 justify-start">
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.user?.longName}
</span>
</div>
</div>
)}
</div>
<TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} />
</div>
</div>
</div>
);
};

95
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const mockNodes = new Map([
[
1,
{ num: 1, user: { longName: "Node A" } },
],
[
2,
{ num: 2, user: { longName: "Node B" } },
],
[
3,
{ num: 3, user: { longName: "Node C" } },
],
]);
beforeEach(() => {
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: mockNodes,
});
});
it("renders the route to destination with SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
route={[1, 2]}
snrTowards={[10, 20, 30]}
/>
);
expect(screen.getByText("Route to destination:")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
snrBack={[35, 45]}
/>
);
expect(screen.getByText("Route back:")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
route={[1]}
/>
);
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
route={[99]}
/>
);
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
});
});

78
src/components/PageComponents/Messages/TraceRoute.tsx

@ -11,6 +11,33 @@ export interface TraceRouteProps {
snrBack?: Array<number>;
}
interface RoutePathProps {
title: string;
startNode?: Protobuf.Mesh.NodeInfo;
endNode?: Protobuf.Mesh.NodeInfo;
path: number[];
snr?: number[];
}
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
const { nodes } = useDevice();
return (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => (
<span key={nodes.get(hop)?.num ?? hop}>
<p>{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<p> {snr?.[i + 1] ?? "??"}dB</p>
</span>
))}
<p>{endNode?.user?.longName}</p>
</span>
);
};
export const TraceRoute = ({
from,
to,
@ -19,43 +46,24 @@ export const TraceRoute = ({
snrTowards,
snrBack,
}: TraceRouteProps) => {
const { nodes } = useDevice();
return (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary">
<p className="font-semibold">Route to destination:</p>
<p>{to?.user?.longName}</p>
<p> {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p>
{route.map((hop, i) => (
<span key={nodes.get(hop)?.num}>
<p>
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
</p>
<p> {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p>
</span>
))}
{from?.user?.longName}
</span>
{routeBack
? (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary">
<p className="font-semibold">Route back:</p>
<p>{from?.user?.longName}</p>
<p> {snrBack?.[0] ? snrBack[0] : "??"}dB</p>
{routeBack.map((hop, i) => (
<span key={nodes.get(hop)?.num}>
<p>
{nodes.get(hop)?.user?.longName ??
`!${numberToHexUnpadded(hop)}`}
</p>
<p> {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p>
</span>
))}
{to?.user?.longName}
</span>
)
: null}
<RoutePath
title="Route to destination:"
startNode={to}
endNode={from}
path={route}
snr={snrTowards}
/>
{routeBack && (
<RoutePath
title="Route back:"
startNode={from}
endNode={to}
path={routeBack}
snr={snrBack}
/>
)}
</div>
);
};

2
src/components/Sidebar.tsx

@ -58,7 +58,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
page: "channels",
},
{
name: "Nodes",
name: `Nodes (${nodes.size})`,
icon: UsersIcon,
page: "nodes",
},

15
src/components/ThemeSwitcher.tsx

@ -24,18 +24,25 @@ export default function ThemeSwitcher({
setPreference(nextPreference);
};
const [firstCharOfPreference = "", ...restOfPreference] = preference;
return (
<button
type="button"
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2",
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100",
className,
)}
onClick={toggleTheme}
aria-label={preference === "system"
? `System theme (currently ${theme}). Click to change theme.`
: `Current theme: ${theme}. Click to change theme.`}
aria-description={"Change current theme"}
>
<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"
>
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}
</span>
{themeIcons[preference]}
</button>
);

11
src/components/UI/Avatar.tsx

@ -1,4 +1,5 @@
import { cn } from "../../core/utils/cn.ts";
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon } from 'lucide-react';
import type React from "react";
type RGBColor = {
@ -12,6 +13,7 @@ interface AvatarProps {
text: string;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
}
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
@ -43,6 +45,7 @@ class ColorUtils {
export const Avatar: React.FC<AvatarProps> = ({
text,
size = "sm",
showError = false,
className,
}) => {
const sizes = {
@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
return (
<div
className={cn(
`
`flex
relative
rounded-full
flex
items-center
justify-center
size-11
font-semibold`,
sizes[size],
className,
@ -88,6 +90,7 @@ export const Avatar: React.FC<AvatarProps> = ({
color: textColor,
}}
>
{showError ? <LockKeyholeOpenIcon className="size-4 absolute bottom-0 right-0 z-10 text-red-500 stroke-3" /> : null}
<p className="p-1">{initials}</p>
</div>
);

2
src/components/UI/Button.tsx

@ -15,7 +15,7 @@ const buttonVariants = cva(
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
"bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:

12
src/components/generic/Table/index.tsx

@ -92,7 +92,7 @@ export const Table = ({ headings, rows }: TableProps) => {
<th
key={heading.title}
scope="col"
className={`py-2 pr-3 pl-6 text-left ${
className={`py-2 pr-3 text-left ${
heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
@ -114,8 +114,16 @@ export const Table = ({ headings, rows }: TableProps) => {
<tbody>
{sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index}>
<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
{row.map((item, index) => (
index === 0 ?
<th
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
scope="row"
>
{item}
</th> :
<td
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"

57
src/core/stores/deviceStore.ts

@ -27,12 +27,18 @@ export type DialogVariant =
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails"
| "unsafeRoles";
| "unsafeRoles"
| "refreshKeys";
type QueueStatus = {
res: number, free: number, maxlen: number
}
type NodeError = {
node: number;
error: string;
}
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
@ -52,6 +58,7 @@ export interface Device {
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice;
activePage: Page;
activeNode: number;
@ -71,6 +78,7 @@ export interface Device {
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
};
unreadCounts: Map<number, number>;
@ -111,6 +119,10 @@ export interface Device {
setMessageDraft: (message: string) => void;
setUnread: (id: number, count: number) => void;
setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean
}
export interface DeviceState {
@ -164,10 +176,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
},
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
nodeErrors: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => {
set(
@ -534,7 +548,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
addTraceRoute: (traceroute) => {
set(
produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
const device = draft.devices.get(id);
if (!device) {
return;
@ -571,10 +584,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
) => {
set(
produce<DeviceState>((draft) => {
console.log("setMessageState called");
const device = draft.devices.get(id);
if (!device) {
console.log("no device found for id");
return;
}
const messageGroup = device.messages[type];
@ -585,7 +596,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const messages = messageGroup.get(messageIndex);
if (!messages) {
console.log("no messages found for id");
return;
}
@ -680,7 +690,42 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}
}),
);
}
},
setNodeError: (nodeNum, error) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
}
}),
);
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.has(nodeNum);
},
});
}),
);

34
src/core/subscriptions.ts

@ -23,15 +23,15 @@ export const subscribeAll = (
) {
return;
}
console.log(`Routing Error: ${routingPacket.data.variant.value}`);
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break;
}
case "routeReply": {
console.log(`Route Reply: ${routingPacket.data.variant.value}`);
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break;
}
case "routeRequest": {
console.log(`Route Request: ${routingPacket.data.variant.value}`);
console.info(`Route Request: ${routingPacket.data.variant.value}`);
break;
}
}
@ -71,8 +71,6 @@ export const subscribeAll = (
});
connection.events.onChannelPacket.subscribe((channel) => {
console.log('channel', channel);
device.addChannel(channel);
});
connection.events.onConfigPacket.subscribe((config) => {
@ -82,10 +80,8 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig);
});
connection.events.onMessagePacket.subscribe((messagePacket) => {
console.log('messagePacket', messagePacket);
connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({
...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
@ -120,8 +116,26 @@ export const subscribeAll = (
connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus);
if (queueStatus.free < 10) {
// start queueing messages
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
}
});
};

8
src/index.tsx

@ -1,4 +1,3 @@
import { scan } from "react-scan";
import "@app/index.css";
import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css";
@ -7,13 +6,6 @@ import { createRoot } from "react-dom/client";
import { App } from "@app/App.tsx";
// run react scan tool in development mode only
// react scan must be the first import and the first line in this file in order to work properly
import.meta.env.VITE_DEBUG_SCAN &&
scan({
enabled: true,
});
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);

6
src/pages/Messages.tsx

@ -13,9 +13,10 @@ import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
export const MessagesPage = () => {
const { channels, nodes, hardware, messages, unreadCounts, setUnread } = useDevice();
const { channels, nodes, hardware, messages, hasNodeError, unreadCounts, setUnread } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => {
@ -91,6 +92,8 @@ export const MessagesPage = () => {
element={
<Avatar
text={node.user?.shortName ?? node.num.toString()}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
size="sm"
/>
}
@ -154,7 +157,6 @@ export const MessagesPage = () => {
)}
</div>
{/* Single message input for both chat types */}
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput
to={messageDestination}

109
src/pages/Nodes.tsx

@ -19,10 +19,22 @@ export interface DeleteNoteDialogProps {
onOpenChange: (open: boolean) => void;
}
function shortNameFromNode(
node: ReturnType<useDevice>["nodes"][number],
): string {
const shortNameOfNode = node.user?.shortName ??
(node.user?.macaddr
? `${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `${numberToHexUnpadded(node.num).slice(-4)}`);
return String(shortNameOfNode);
}
const NodesPage = (): JSX.Element => {
const { nodes, hardware, connection } = useDevice();
console.log(connection);
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@ -63,7 +75,6 @@ const NodesPage = (): JSX.Element => {
};
}, [connection]);
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
@ -89,82 +100,70 @@ const NodesPage = (): JSX.Element => {
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar text={node.user?.shortName.toString() ?? "UNK"} />
<Avatar text={shortNameFromNode(node)} />
</div>,
<h1
key="shortName"
onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer"
>
{node.user?.shortName ??
(node.user?.macaddr
? `${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
</h1>,
<h1
key="longName"
onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer"
onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node);
}}
className="cursor-pointer underline"
tabIndex={0}
role="button"
>
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
? `Meshtastic ${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `!${numberToHexUnpadded(node.num)}`)}
</h1>,
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
<Mono key="lastHeard">
{node.lastHeard === 0
? <p>Never</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>,
<Mono className="px-4" key="lastHeard">
{node.lastHeard === 0 ? (
<p className="px-4">Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
<Mono key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>,
<Mono key="snr">
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600 mx-auto" />
) : (
<LockOpenIcon className="text-yellow-300 mx-auto" />
)}
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
])}
/>

9
vite.config.ts

@ -44,11 +44,8 @@ export default defineConfig({
server: {
port: 3000,
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}
},
optimizeDeps: {
exclude: ['react-scan']
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
});
Loading…
Cancel
Save