141 Commits
Author SHA1 Message Date
Hunter ThornsberryandGitHub 2e12b27566 Merge pull request #277 from KomelT/feature/security-tab
Update key generation
2024-09-08 18:13:42 -04:00
Hunter Thornsberry 989fad7e17 biome 2024-09-07 20:24:45 -04:00
Hunter Thornsberry f7a2e5f76b Invert adminKey disabledBy 2024-09-07 20:24:18 -04:00
Hunter Thornsberry 2b81fc47e2 disable the public key input 2024-09-07 20:19:51 -04:00
Hunter Thornsberry b522113cd7 bad merge on Security.tsx 2024-09-07 20:16:29 -04:00
Hunter ThornsberryandGitHub fce642c24e Merge branch 'pki' into feature/security-tab 2024-09-07 19:54:57 -04:00
Tilen Komel 5e72510bef Removed submit button 2024-09-06 09:33:13 +02:00
Hunter Thornsberry 88efdc4758 Merge branch 'master' into pki 2024-09-05 19:26:32 -04:00
Ben MeadorsandGitHub 530d33d1e4 Merge pull request #290 from Hunter275/disabled-dynamicform
CI / build-and-package (push) Canceled after 0s
Rework disabled in DynamicForms
2024-09-05 06:19:17 -05:00
Hunter ThornsberryandGitHub 6375187d50 Merge branch 'pki' into disabled-dynamicform 2024-09-04 21:56:24 -04:00
Hunter Thornsberry 300fb5c3e5 fix disabled check location 2024-09-04 21:54:48 -04:00
Hunter Thornsberry 0dcc2b1975 rewrite disabled dynamicforms stuff and fix factory reset in command palette 2024-09-04 21:50:23 -04:00
Hunter ThornsberryandGitHub 41acc4d25d Merge pull request #289 from Hunter275/gh-actions
GitHub Actions: Releases
2024-09-04 16:15:41 -04:00
Tilen Komel d10c010b9a Dont disable PKI on Allow Legacy Admin 2024-09-04 18:50:49 +02:00
Hunter Thornsberry 80ab44c8db don't release on release 2024-09-04 00:39:12 -04:00
Hunter Thornsberry 7895df2d9f Stop release on ci 2024-09-04 00:35:22 -04:00
Hunter ThornsberryandGitHub c780437355 Merge pull request #286 from TheCyberRonin/feature/provide-compressed-build-during-pr
feat: Provide compressed build during PR GitHub Action
2024-09-04 00:12:26 -04:00
Tilen Komel 9d4aa05316 Updated pki regenerate dialog 2024-09-02 15:45:58 +02:00
Tilen Komel 354d04592b Fix build 2024-09-02 15:05:04 +02:00
Tilen Komel 9bea6870bb Add submit button to work 2024-09-02 13:11:42 +02:00
Tilen Komel d0bd02980d Update private key generation 2024-09-02 13:10:59 +02:00
Hunter ThornsberryandGitHub 7d1135b9dc Merge pull request #288 from mrfyda/master
Change 'powerScreenEnabled' config type to toggle
2024-09-01 19:00:25 -04:00
Tilen Komel f66332b3e3 Handle undefined booleans 2024-08-31 15:58:12 +02:00
Rafael Cortês 05a6b6293e Change 'powerScreenEnabled' config type to toggle 2024-08-31 10:51:53 +01:00
Hunter ThornsberryandGitHub b9a8a2ba6c Merge pull request #287 from TheCyberRonin/feature/scope-ci-to-push-on-master
feat: Only do `CI` GHA on push to `master`
2024-08-30 12:31:09 -04:00
Ronin c16070f02b feat: Only do CI GHA on push to master 2024-08-29 21:54:58 -04:00
Ronin 3e6653a98f chore: Fix typo in package name for uploading 2024-08-29 21:31:04 -04:00
Ronin 59126ca939 feat: Add a build step for build.tar 2024-08-29 21:27:37 -04:00
Tilen Komel 4bde402a53 Add key generation confirmation 2024-08-29 23:19:12 +02:00
Tilen Komel 4cf91272de Removed admin key generation from security 2024-08-29 23:19:12 +02:00
Tilen Komel c711c39aa9 Add inputChange to FormInpit 2024-08-29 23:19:12 +02:00
Tilen Komel c4342f1a2b Update passwordGenerator disabled 2024-08-29 23:19:12 +02:00
Tilen Komel afc45588fa Fixed security key generation 2024-08-29 23:19:12 +02:00
Tilen Komel 22cd5aa88d Add key generation 2024-08-29 23:19:12 +02:00
Tilen Komel 8c4c8a760e Add @noble/curves 2024-08-29 23:19:11 +02:00
Tilen Komel 0911df6b0d Add dynamic bit to password generator 2024-08-29 23:16:02 +02:00
Tilen Komel 06c20fa950 Add dynamic value to input 2024-08-29 23:16:02 +02:00
Hunter ThornsberryandGitHub cd0056783c Merge pull request #284 from KomelT/pki
Upgrade @meshtastic/js 2.3.7-0 > 2.3.7-1
2024-08-29 17:13:10 -04:00
Tilen Komel b1baf2d8e6 .factoryReset() split 2024-08-29 23:04:13 +02:00
Tilen Komel 2d106eb3a9 Upgrade @meshtastic/js 2.3.7-0 > 2.3.7-1 2024-08-29 22:52:36 +02:00
Hunter ThornsberryandGitHub 22d900a831 Merge pull request #278 from Hunter275/dashboard-footer
flex-grow to make footer stick to bottom
2024-08-26 15:33:40 -04:00
Hunter Thornsberry c6cc5e5e6f biome 2024-08-26 14:32:18 -04:00
Hunter Thornsberry f0d8db1c87 flex-grow to make footer stick to bottom 2024-08-26 14:10:43 -04:00
Hunter ThornsberryandGitHub 8c37be4af3 Merge pull request #274 from rcarteraz/add-footer 2024-08-24 11:50:51 -04:00
Hunter Thornsberry 076dae80b7 first working version 2024-08-23 18:58:22 -04:00
Hunter Thornsberry 7d4001ea9d Update footer and initial groundword 2024-08-23 18:40:17 -04:00
Hunter ThornsberryandGitHub 4215eb1a55 Merge pull request #276 from KomelT/feature/security-tab
Key verification & generation
2024-08-22 13:28:56 -04:00
Tilen Komel e3fad3015f Add hide toggle to password generator 2024-08-22 15:45:11 +02:00
Tilen Komel 02a63c213e Add key verification & generation 2024-08-22 14:08:17 +02:00
Hunter ThornsberryandGitHub 737fbb4320 Merge pull request #275 from KomelT/feature/security-tab
Feature: Security tab
2024-08-21 13:45:31 -04:00
Tilen Komel 66fb300575 Moved prop. from Device to Security & Hide PKI Eye 2024-08-21 18:52:03 +02:00
Tilen Komel cf423620c4 Error & formating fixes 2024-08-21 17:41:24 +02:00
Tilen Komel 8bb0a96744 Downgraded @buf/meshtastic_protobufs.bufbuild_es from 2.0 to 1.10 2024-08-21 13:13:42 +02:00
Tilen Komel 65247c4f35 Update Security tab 2024-08-20 18:51:02 +02:00
Tilen Komel af51659e71 Add always to disabled by in dynamics form 2024-08-20 18:50:19 +02:00
Tilen Komel f0eae444c7 Update descriptions in Security 2024-08-20 18:10:39 +02:00
Tilen Komel 6d3bf39b76 Updated @buf/meshtastic_protobufs.bufbuild_es to lastest 2024-08-20 18:06:27 +02:00
Tilen Komel 7d5950d6cc Add SecurityValidation to security channel 2024-08-20 17:23:42 +02:00
Tilen Komel d3836a7250 Add class SecurityValidation 2024-08-20 17:19:36 +02:00
Tilen Komel f64b96527e Add security to device config store 2024-08-20 17:18:55 +02:00
Tilen Komel be9169f56f Use protobuf pki branch, tmp for dev 2024-08-20 15:08:13 +02:00
Tilen Komel f6be57224e Updated inputs 2024-08-20 15:04:10 +02:00
Tilen Komel a8dcab0844 Added security input fields 2024-08-20 00:33:27 +02:00
Tilen Komel 049f3de919 Added security tab 2024-08-20 00:33:27 +02:00
rcarteraz 6c1f140ad1 oops -- helps if you use the full link 2024-08-19 13:31:22 -07:00
rcarteraz 4c4be2e18f temp solution 2024-08-19 13:20:56 -07:00
Hunter ThornsberryandGitHub 2b34d78a86 Merge pull request #266 from Hunter275/issue-261-password-generator
PSK Generator
2024-08-15 19:59:02 -04:00
Hunter Thornsberry 54a7b88146 rewrite PSK validation 2024-08-10 19:50:21 -04:00
Hunter Thornsberry e7892fd6a0 biome 2024-08-10 19:07:02 -04:00
Hunter Thornsberry 1eedb6d97b validation 2024-08-10 18:50:47 -04:00
Hunter Thornsberry faf094084c remove used imports and rename clickEventCb 2024-08-06 01:04:44 -04:00
Hunter ThornsberryandGitHub 6bbe995ee5 Merge branch 'meshtastic:master' into issue-261-password-generator 2024-08-06 00:59:04 -04:00
Hunter Thornsberry 32acd23362 biome 2024-08-06 00:58:26 -04:00
Hunter Thornsberry cd0fcbbf90 initial working version 2024-08-06 00:50:17 -04:00
Hunter ThornsberryandGitHub cfc2ea0fe5 Merge pull request #268 from KomelT/fix/hops-singular
Fixed hops singular form
2024-08-06 00:45:10 -04:00
Tilen Komel 5771e1b733 Fixed hops singular form 2024-08-05 16:33:57 +02:00
Hunter Thornsberry 1c7c44a472 biome linting 2024-07-28 17:46:51 -04:00
Hunter Thornsberry 9c6aff534a a bit of cleanup 2024-07-28 16:43:41 -04:00
Hunter Thornsberry 38b7e600b1 key generation 2024-07-28 15:57:17 -04:00
Hunter Thornsberry 1ae879342a added password generator 2024-07-28 15:09:11 -04:00
Hunter ThornsberryandGitHub 2528391814 Merge pull request #260 from AddisonTustin/qr-code-query-string
fix: move qrcode URL query-string before fragment
2024-07-24 19:13:27 -04:00
Hunter ThornsberryandGitHub d6147f5b7f Merge pull request #265 from fifieldt/fix-message-limit
Allow single character messages in MessageInput
2024-07-24 13:16:07 -04:00
Tom Fifield 8c17a8be38 Allow single character messages in MessageInput
In character-based languages (eg Chinese, Japanese, Korean), there
are many common interjections that are single characters. Allow
single character messages to support these users to communicate
naturally.

fixes meshtastic/web#264
2024-07-24 12:47:39 +08:00
Addison Tustin a4e2e7eec1 fix: move qrcode URL query-string before fragment 2024-07-20 12:23:30 -07:00
Hunter ThornsberryandGitHub 4653656420 Merge pull request #211 from fifieldt/traceroute
Add Traceroute Feature
2024-07-04 17:43:41 -04:00
Hunter Thornsberry d42e8c10a0 minor changes and biome fixes 2024-07-04 17:29:40 -04:00
Hunter Thornsberry 0955bbe24b Merge remote-tracking branch 'hunter275/traceroute' into traceroute 2024-07-04 17:09:31 -04:00
Hunter ThornsberryandGitHub f3fbe75c66 Merge branch 'meshtastic:master' into traceroute 2024-07-04 17:06:28 -04:00
Hunter Thornsberry bcebfd211b Merge remote-tracking branch 'meshtastic/master' into traceroute 2024-07-04 17:05:33 -04:00
Hunter ThornsberryandGitHub 5bd385b535 Merge pull request #258 from Hunter275/issue-257-node-sorting
fix sorting + new protobufs
2024-06-28 22:29:18 -04:00
Hunter Thornsberry 9f5604971b node stuff 2024-06-28 12:41:02 -04:00
Hunter Thornsberry 93d9f721b4 protobuf changes 2024-06-28 12:36:32 -04:00
Hunter Thornsberry 569bb09f94 fix sorting 2024-06-28 12:30:06 -04:00
Hunter ThornsberryandGitHub 2566395168 Merge pull request #252 from Hunter275/issue-251-mqtt-saving
Fix MQTT settings
2024-06-26 09:24:29 -04:00
Hunter Thornsberry 8893a196c2 ignore vercel.json in biome 2024-06-25 14:31:37 -04:00
Hunter Thornsberry fdbcdd955b vercel fix v2 2024-06-25 14:18:03 -04:00
Hunter Thornsberry 723c9ee5d8 vercel fix 2024-06-25 14:16:19 -04:00
Hunter Thornsberry 22c862fd08 revert package.json 2024-06-25 12:43:14 -04:00
Hunter Thornsberry c47aeb652b Merge branch 'issue-251-mqtt-saving' of https://github.com/Hunter275/web into issue-251-mqtt-saving 2024-06-25 12:39:32 -04:00
Hunter Thornsberry 2f37390985 Linting 2024-06-25 12:39:06 -04:00
Hunter Thornsberry 205c9a21e3 Merge branch 'master' into issue-251-mqtt-saving 2024-06-25 12:38:48 -04:00
Hunter ThornsberryandGitHub 29fab4b6f3 Merge branch 'master' into issue-251-mqtt-saving 2024-06-23 20:19:29 -04:00
Hunter ThornsberryandGitHub 0dddf6ad2f Merge pull request #213 from bmv437/feature/enforce-biome
Update biome, fix and enforce recommended rules
2024-06-23 20:17:44 -04:00
Hunter Thornsberry b24c9312c4 linting fixes 2024-06-23 20:15:33 -04:00
Hunter Thornsberry 268ad4908a remove duplicate icon 2024-06-23 20:10:18 -04:00
Hunter ThornsberryandGitHub fa127eda33 Merge branch 'master' into feature/enforce-biome 2024-06-23 20:08:56 -04:00
Hunter ThornsberryandGitHub 8942468c2c Merge pull request #253 from Server2003User/master
Add device firmware version to WebUI
2024-06-23 19:10:59 -04:00
Hunter Thornsberry 3aedc7b7c2 fixes 2024-06-23 19:03:59 -04:00
server2003userandGitHub 401a594ec7 Add Firmware 2024-06-23 18:24:46 -04:00
Hunter Thornsberry a5a37cd4ab update setWorkingModuleConfig to include mapReportSettings 2024-06-22 21:14:50 -04:00
Hunter Thornsberry 283f548136 add optional to publishIntervalSecs and positionPrecision 2024-06-22 20:54:45 -04:00
Brandon Vandegrift 93b139c268 Update biome, fix and enforce recommended rules 2024-06-22 12:54:44 -04:00
Tom Fifield 9e04c1f486 Merge branch 'master' into traceroute 2024-06-21 15:36:03 +08:00
Hunter ThornsberryandGitHub 6b268b79ed Merge pull request #165 from geftactics/master
Fix sorting on peers table
2024-06-20 22:02:11 -04:00
Hunter Thornsberry 11fe2fdb35 fix sorting 2024-06-20 21:55:30 -04:00
Hunter ThornsberryandGitHub 69f67e3657 Merge pull request #244 from Hunter275/issues-242-243-dropdown-and-warning
update MQTT precision dropdown and encryption warning
2024-06-18 15:36:32 -04:00
Hunter Thornsberry 4143249c55 remove mention of channel 2024-06-18 14:19:29 -04:00
Hunter Thornsberry 4c1737bc44 remove precise location mention as there is a setting for that 2024-06-18 14:18:55 -04:00
Hunter Thornsberry 17c44054be update MQTT precision dropdown and encryption warning 2024-06-17 17:01:29 -04:00
Tom FifieldandGitHub 026256ac5c Merge branch 'meshtastic:master' into master 2024-06-17 12:14:11 +08:00
Hunter Thornsberry e9a681ab21 Add tr column and Toast when starting tr 2024-06-16 11:01:39 -04:00
Hunter Thornsberry bcac95e7ed Merge remote-tracking branch 'origin/master' into traceroute 2024-06-16 11:01:06 -04:00
Tom Fifield daff97a5e0 Fix double double arrow on unknown nodes in multi-hop traceroutes 2024-05-27 21:04:12 +08:00
Tom Fifield 452f2581e2 Fix incorrect use of operator build error. 2024-05-27 21:02:42 +08:00
Tom Fifield 569c2daa09 Fix traceroute ordering for multi-hop traceroutes 2024-05-27 20:57:09 +08:00
Tom Fifield e825a737b0 Fix build errors 2024-05-27 12:08:32 +08:00
Tom Fifield 54c983439c Fix type Number - number 2024-05-27 11:52:33 +08:00
Tom Fifield 7b6b8daeba Fix build errors
Several types were wrong.
2024-05-27 11:40:59 +08:00
Tom Fifield 0f89e04bb0 Only show traceroutes on node direct message pageso 2024-05-27 10:56:34 +08:00
Tom Fifield a728b848e1 Don't show traceroute button on Channels
It doesn't work, it's only for nodes!
2024-05-27 10:39:45 +08:00
Tom Fifield f16cce90c8 Fix case where intermediary node in traceroute is unknown. 2024-05-27 10:09:59 +08:00
Tom Fifield 056a194ede Add formatting logic for multi-hop traceroutes 2024-05-27 09:53:20 +08:00
Tom Fifield be3117651c Add basic traceroute display
This patch adds basic traceroute display functionality on the
Messages page for an individual node.
2024-05-26 21:00:30 +08:00
Tom Fifield 86263990e6 Add Toast lib to Messages page 2024-05-26 18:41:48 +08:00
Tom Fifield 390b16812a Subscribe to and store traceroutePackets 2024-05-26 18:41:13 +08:00
Tom Fifield 1458497fc3 Add button to Message page to send a traceroute to a node.
As noted in meshtastic/web#179, it would be great to be able to
send traceroutes from the web application.

This patch adds a button to the messages page that sends a
traceroute to the selected node.
2024-05-26 15:42:50 +08:00
Tom Fifield 97b9570196 Revert "Add button to Message page to send a traceroute to a node."
This reverts commit 6e145421ef.
2024-05-26 15:41:37 +08:00
Chris DrackettandTom Fifield 82c1d3e3f1 Support floats in number fields 2024-05-26 15:41:32 +08:00
Tom Fifield a7237bcb2b Revert "Support floats in number fields"
This reverts commit 5fdd07ee68.
2024-05-26 15:40:55 +08:00
Tom Fifield 6e145421ef Add button to Message page to send a traceroute to a node.
As noted in meshtastic/web#179, it would be great to be able to
send traceroutes from the web application.

This patch adds a button to the messages page that sends a
traceroute to the selected node.
2024-05-26 15:37:17 +08:00
geftacticsandGitHub 98f8965aed Fix sorting on peers table
Enables sorting by clicking column heading.
Defaults to most recent contacts at the top
2024-02-22 12:19:18 +00:00
74 changed files with 5805 additions and 3678 deletions
+4 -40
View File
@@ -1,6 +1,9 @@
name: CI name: CI
on: push on:
push:
branches:
- master
permissions: permissions:
contents: write contents: write
@@ -21,42 +24,3 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Package Output
run: pnpm package
- name: Upload Artifact
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
files: |
./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: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ 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 }}"
+9
View File
@@ -19,3 +19,12 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
+55
View File
@@ -0,0 +1,55 @@
name: 'Release'
on:
release:
types: [released]
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ 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 }}"
+5 -2
View File
@@ -1,4 +1,7 @@
{ {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
},
"editor.formatOnSave": true
} }
+4 -3
View File
@@ -1,10 +1,11 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"files": { "files": {
"ignoreUnknown": true "ignoreUnknown": true,
"ignore": ["vercel.json"]
}, },
"vcs": { "vcs": {
"enabled": true, "enabled": true,
@@ -20,7 +21,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"all": true "recommended": true
} }
} }
} }
+37 -34
View File
@@ -6,8 +6,9 @@
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && pnpm check && vite build ",
"check": "biome check .", "check": "biome check .",
"check:fix": "pnpm check --write",
"preview": "vite preview", "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/)" "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/)"
}, },
@@ -20,64 +21,66 @@
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.8.0", "@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.4-0", "@meshtastic/js": "2.3.7-1",
"@radix-ui/react-accordion": "^1.1.2", "@noble/curves": "^1.5.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.1",
"@turf/turf": "^6.5.0", "@turf/turf": "^6.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"immer": "^10.0.4", "crypto-random-string": "^5.0.0",
"immer": "^10.1.1",
"lucide-react": "^0.363.0", "lucide-react": "^0.363.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "4.1.2", "maplibre-gl": "4.1.2",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.52.0",
"react-map-gl": "7.1.7", "react-map-gl": "7.1.7",
"react-qrcode-logo": "^2.9.0", "react-qrcode-logo": "^2.10.0",
"rfc4648": "^1.5.3", "rfc4648": "^1.5.3",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6", "timeago-react": "^3.0.6",
"zustand": "4.5.2" "zustand": "4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.6.3", "@biomejs/biome": "^1.8.2",
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2", "@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240820152623-fac6975bbc78.1",
"@types/chrome": "^0.0.263", "@types/chrome": "^0.0.263",
"@types/node": "^20.11.30", "@types/node": "^20.14.9",
"@types/react": "^18.2.73", "@types/react": "^18.3.3",
"@types/react-dom": "^18.2.23", "@types/react-dom": "^18.3.0",
"@types/w3c-web-serial": "^1.0.6", "@types/w3c-web-serial": "^1.0.6",
"@types/web-bluetooth": "^0.0.20", "@types/web-bluetooth": "^0.0.20",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"gzipper": "^7.2.0", "gzipper": "^7.2.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.4",
"tar": "^6.2.1", "tar": "^6.2.1",
"tslib": "^2.6.2", "tslib": "^2.6.3",
"typescript": "^5.4.3", "typescript": "^5.5.2",
"vite": "^5.2.6", "vite": "^5.3.1",
"vite-plugin-environment": "^1.1.3" "vite-plugin-environment": "^1.1.3"
} }
} }
+4264 -3173
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {},
} },
}; };
+6 -1
View File
@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js"; import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js"; import { Toaster } from "@components/Toaster.js";
import Footer from "@components/UI/Footer.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<Dashboard /> <>
<Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)} )}
</div> </div>
</div> </div>
+11 -4
View File
@@ -19,7 +19,7 @@ import {
LayersIcon, LayersIcon,
LayoutIcon, LayoutIcon,
LinkIcon, LinkIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
MoonIcon, MoonIcon,
@@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
}, },
}, },
{ {
label: "Factory Reset", label: "Factory Reset Device",
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryReset(); connection?.factoryResetDevice();
},
},
{
label: "Factory Reset Config",
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
}, },
}, },
], ],
@@ -350,7 +357,7 @@ export const CommandPalette = (): JSX.Element => {
window.addEventListener("keydown", handleKeydown); window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown);
}, []); }, [setCommandPaletteOpen]);
return ( return (
<CommandDialog <CommandDialog
+1 -1
View File
@@ -9,8 +9,8 @@ import {
LanguagesIcon, LanguagesIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
SunIcon,
SearchIcon, SearchIcon,
SunIcon,
} from "lucide-react"; } from "lucide-react";
export const DeviceSelector = (): JSX.Element => { export const DeviceSelector = (): JSX.Element => {
+1 -1
View File
@@ -1,9 +1,9 @@
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js"; import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js"; import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js"; import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js"; import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {
+24 -9
View File
@@ -26,19 +26,34 @@ export const ImportDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ImportDialogProps): JSX.Element => { }: ImportDialogProps): JSX.Element => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false); const [validUrl, setValidUrl] = useState<boolean>(false);
const { connection } = useDevice(); const { connection } = useDevice();
useEffect(() => { useEffect(() => {
const base64String = qrCodeUrl.split("e/#")[1]; // the channel information is contained in the URL's fragment, which will be present after a
const paddedString = base64String // non-URL encoded `#`.
?.padEnd(base64String.length + ((4 - (base64String.length % 4)) % 4), "=")
.replace(/-/g, "+")
.replace(/_/g, "/");
try { try {
const channelsUrl = new URL(importDialogInput);
if (
(channelsUrl.hostname !== "meshtastic.org" &&
channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash
) {
throw "Invalid Meshtastic URL";
}
const encodedChannelConfig = channelsUrl.hash.substring(1);
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
.replace(/_/g, "/");
setChannelSet( setChannelSet(
Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)), Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)),
); );
@@ -47,7 +62,7 @@ export const ImportDialog = ({
setValidUrl(false); setValidUrl(false);
setChannelSet(undefined); setChannelSet(undefined);
} }
}, [qrCodeUrl]); }, [importDialogInput]);
const apply = () => { const apply = () => {
channelSet?.settings.map((ch, index) => { channelSet?.settings.map((ch, index) => {
@@ -87,10 +102,10 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label> <Label>Channel Set/QR Code URL</Label>
<Input <Input
value={qrCodeUrl} value={importDialogInput}
suffix={validUrl ? "✅" : "❌"} suffix={validUrl ? "✅" : "❌"}
onChange={(e) => { onChange={(e) => {
setQrCodeUrl(e.target.value); setImportDialogInput(e.target.value);
}} }}
/> />
{validUrl && ( {validUrl && (
@@ -0,0 +1,39 @@
import { Button } from "@components/UI/Button.js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
export interface PkiRegenerateDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps): JSX.Element => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+21 -11
View File
@@ -9,10 +9,10 @@ import {
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js"; import { Label } from "@components/UI/Label.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react"; import { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps { export interface QRDialogProps {
@@ -32,7 +32,7 @@ export const QRDialog = ({
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>(); const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values()); const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => { useEffect(() => {
const channelsToEncode = allChannels const channelsToEncode = allChannels
@@ -50,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_"); .replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`); setQrCodeUrl(
}, [channels, selectedChannels, qrCodeAdd, loraConfig]); `https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -98,17 +100,25 @@ export const QRDialog = ({
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="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-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"
}`}
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels Add Channels
</button> </button>
<button <button
type="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-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"
}`}
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels Replace Channels
</button> </button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -37,7 +37,7 @@ export const RebootDialog = ({
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{ action={{
icon: ClockIcon, icon: ClockIcon,
onClick() { onClick() {
+3 -1
View File
@@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button> <Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+1 -1
View File
@@ -38,7 +38,7 @@ export const ShutdownDialog = ({
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix="Minutes" suffix="Minutes"
/> />
<Button <Button
+28 -12
View File
@@ -1,17 +1,17 @@
import { import {
DynamicFormField, DynamicFormField,
FieldProps, type FieldProps,
} from "@components/Form/DynamicFormField.js"; } from "@components/Form/DynamicFormField.js";
import { FieldWrapper } from "@components/Form/FormWrapper.js"; import { FieldWrapper } from "@components/Form/FormWrapper.js";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { H4 } from "@components/UI/Typography/H4.js"; import { H4 } from "@components/UI/Typography/H4.js";
import { Subtle } from "@components/UI/Typography/Subtle.js"; import { Subtle } from "@components/UI/Typography/Subtle.js";
import { import {
Control, type Control,
DefaultValues, type DefaultValues,
FieldValues, type FieldValues,
Path, type Path,
SubmitHandler, type SubmitHandler,
useForm, useForm,
} from "react-hook-form"; } from "react-hook-form";
@@ -23,10 +23,12 @@ interface DisabledBy<T> {
export interface BaseFormBuilderProps<T> { export interface BaseFormBuilderProps<T> {
name: Path<T>; name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[]; disabledBy?: DisabledBy<T>[];
label: string; label: string;
description?: string; description?: string;
properties?: {}; validationText?: string;
properties?: Record<string, unknown>;
} }
export interface GenericFormElementProps<T extends FieldValues, Y> { export interface GenericFormElementProps<T extends FieldValues, Y> {
@@ -39,11 +41,12 @@ export interface DynamicFormProps<T extends FieldValues> {
onSubmit: SubmitHandler<T>; onSubmit: SubmitHandler<T>;
submitType?: "onChange" | "onSubmit"; submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean; hasSubmitButton?: boolean;
// defaultValues?: DeepPartial<T>;
defaultValues?: DefaultValues<T>; defaultValues?: DefaultValues<T>;
fieldGroups: { fieldGroups: {
label: string; label: string;
description: string; description: string;
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[]; fields: FieldProps<T>[];
}[]; }[];
} }
@@ -60,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues, defaultValues: defaultValues,
}); });
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => { const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false; if (!disabledBy) return false;
return disabledBy.some((field) => { return disabledBy.some((field) => {
const value = getValues(field.fieldName); const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value; if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number") if (typeof value === "number")
return field.invert return field.invert
@@ -94,12 +102,20 @@ export function DynamicForm<T extends FieldValues>({
</div> </div>
{fieldGroup.fields.map((field) => ( {fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}> <FieldWrapper
key={field.label}
label={field.label}
description={field.description}
valid={
field.validationText === undefined ||
field.validationText === ""
}
validationText={field.validationText}
>
<DynamicFormField <DynamicFormField
key={field.label}
field={field} field={field}
control={control} control={control}
disabled={isDisabled(field.disabledBy)} disabled={isDisabled(field.disabledBy, field.disabled)}
/> />
</FieldWrapper> </FieldWrapper>
))} ))}
+26 -4
View File
@@ -1,12 +1,26 @@
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js"; import {
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js"; GenericInput,
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js"; type InputFieldProps,
} from "@components/Form/FormInput.js";
import {
PasswordGenerator,
type PasswordGeneratorProps,
} from "@components/Form/FormPasswordGenerator.js";
import {
type SelectFieldProps,
SelectInput,
} from "@components/Form/FormSelect.js";
import {
type ToggleFieldProps,
ToggleInput,
} from "@components/Form/FormToggle.js";
import type { Control, FieldValues } from "react-hook-form"; import type { Control, FieldValues } from "react-hook-form";
export type FieldProps<T> = export type FieldProps<T> =
| InputFieldProps<T> | InputFieldProps<T>
| SelectFieldProps<T> | SelectFieldProps<T>
| ToggleFieldProps<T>; | ToggleFieldProps<T>
| PasswordGeneratorProps<T>;
export interface DynamicFormFieldProps<T extends FieldValues> { export interface DynamicFormFieldProps<T extends FieldValues> {
field: FieldProps<T>; field: FieldProps<T>;
@@ -35,6 +49,14 @@ export function DynamicFormField<T extends FieldValues>({
return ( return (
<SelectInput field={field} control={control} disabled={disabled} /> <SelectInput field={field} control={control} disabled={disabled} />
); );
case "passwordGenerator":
return (
<PasswordGenerator
field={field}
control={control}
disabled={disabled}
/>
);
case "multiSelect": case "multiSelect":
return <div>tmp</div>; return <div>tmp</div>;
} }
+8 -4
View File
@@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form"; import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: { properties?: {
value?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
step?: number; step?: number;
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type} type={field.type}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value} value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) => onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange( onChange(
field.type === "number" field.type === "number"
? Number.parseFloat(e.target.value) ? Number.parseFloat(e.target.value)
: e.target.value, : e.target.value,
) );
} }}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}
@@ -0,0 +1,46 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
import { Generator } from "@components/UI/Generator.js";
import type { ChangeEventHandler, MouseEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
buttonClick: MouseEventHandler;
}
export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
buttonClick={field.buttonClick}
value={value}
variant={field.validationText ? "invalid" : "default"}
buttonText="Generate"
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>
);
}
+2 -2
View File
@@ -9,7 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.js"; } from "@components/UI/Select.js";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select" | "multiSelect"; type: "select" | "multiSelect";
@@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
: []; : [];
return ( return (
<Select <Select
onValueChange={(e) => onChange(parseInt(e))} onValueChange={(e) => onChange(Number.parseInt(e))}
disabled={disabled} disabled={disabled}
value={value?.toString()} value={value?.toString()}
{...remainingProperties} {...remainingProperties}
+2 -2
View File
@@ -3,8 +3,8 @@ import type {
GenericFormElementProps, GenericFormElementProps,
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Switch } from "@components/UI/Switch.js"; import { Switch } from "@components/UI/Switch.js";
import { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> { export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle"; type: "toggle";
+7
View File
@@ -5,12 +5,16 @@ export interface FieldWrapperProps {
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
valid?: boolean;
validationText?: string;
} }
export const FieldWrapper = ({ export const FieldWrapper = ({
label, label,
description, description,
children, children,
valid,
validationText,
}: FieldWrapperProps): JSX.Element => ( }: FieldWrapperProps): JSX.Element => (
<div className="pt-6 sm:pt-5"> <div className="pt-6 sm:pt-5">
<div role="group" aria-labelledby="label-notifications"> <div role="group" aria-labelledby="label-notifications">
@@ -19,6 +23,9 @@ export const FieldWrapper = ({
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="max-w-lg"> <div className="max-w-lg">
<p className="text-sm text-gray-500">{description}</p> <p className="text-sm text-gray-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div> <div className="flex items-center">{children}</div>
</div> </div>
+95 -14
View File
@@ -1,9 +1,11 @@
import type{ ChannelValidation } from "@app/validation/channel.js"; import type { ChannelValidation } from "@app/validation/channel.js";
import { DynamicForm } from "@components/Form/DynamicForm.js"; import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useToast } from "@core/hooks/useToast.js"; import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useState } from "react";
export interface SettingsPanelProps { export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel; channel: Protobuf.Channel.Channel;
@@ -13,15 +15,27 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { config, connection, addChannel } = useDevice(); const { config, connection, addChannel } = useDevice();
const { toast } = useToast(); const { toast } = useToast();
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [bitCount, setBits] = useState<number>(
channel?.settings?.psk.length ?? 16,
);
const [validationText, setValidationText] = useState<string>();
const onSubmit = (data: ChannelValidation) => { const onSubmit = (data: ChannelValidation) => {
const channel = new Protobuf.Channel.Channel({ const channel = new Protobuf.Channel.Channel({
...data, ...data,
settings: { settings: {
...data.settings, ...data.settings,
psk: toByteArray(data.settings.psk ?? ""), psk: toByteArray(pass),
moduleSettings: { moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0, positionPrecision: data.settings.positionEnabled
} ? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
}, },
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
@@ -32,6 +46,38 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}); });
}; };
const clickEvent = () => {
setPass(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
};
const validatePass = (input: string, count: number) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
};
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setPass(psk);
validatePass(psk, bitCount);
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setBits(count);
validatePass(pass, count);
};
return ( return (
<DynamicForm<ChannelValidation> <DynamicForm<ChannelValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -42,10 +88,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
...{ ...{
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: pass,
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0, positionEnabled:
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32, channel?.settings?.moduleSettings?.positionPrecision !==
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : 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,
}, },
}, },
}} }}
@@ -65,12 +118,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}, },
}, },
{ {
type: "password", type: "passwordGenerator",
name: "settings.psk", name: "settings.psk",
label: "pre-Shared Key", label: "pre-Shared Key",
description: "16, or 32 bytes", description: "256, 128, or 8 bit PSKs allowed",
validationText: validationText,
devicePSKBitCount: bitCount ?? 0,
inputChange: inputChangeEvent,
selectChange: selectChangeEvent,
buttonClick: clickEvent,
properties: { properties: {
// act value: pass,
}, },
}, },
{ {
@@ -111,9 +169,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description: description:
"If not sharing precise location, position shared on channel will be accurate within this distance", "If not sharing precise location, position shared on channel will be accurate within this distance",
properties: { properties: {
enumValue: config.display?.units == 0 ? enumValue:
{ "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 } : config.display?.units === 0
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 } ? {
"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,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
}, },
}, },
], ],
@@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
formatEnumName: true, formatEnumName: true,
}, },
}, },
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Enable the device's serial console",
},
{
type: "toggle",
name: "debugLogEnabled",
label: "Enabled Debug Log",
description:
"Output debugging information to the device's serial port (auto disables when serial client is connected)",
},
{ {
type: "number", type: "number",
name: "buttonGpio", name: "buttonGpio",
@@ -86,12 +73,6 @@ export const Device = (): JSX.Element => {
label: "Double Tap as Button Press", label: "Double Tap as Button Press",
description: "Treat double tap as button press", description: "Treat double tap as button press",
}, },
{
type: "toggle",
name: "isManaged",
label: "Managed",
description: "Is this device managed by a mesh administator",
},
{ {
type: "toggle", type: "toggle",
name: "disableTripleClick", name: "disableTripleClick",
@@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit", label: "Hop Limit",
description: "Maximum number of hops", description: "Maximum number of hops",
properties: { properties: {
enumValue: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7} enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
}, },
}, },
{ {
@@ -0,0 +1,245 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig } = useDevice();
const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: toByteArray(adminKey),
privateKey: toByteArray(privateKey),
publicKey: toByteArray(publicKey),
},
},
}),
);
};
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
try {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
} catch (e) {
console.error(e);
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
}
};
const privateKeyClickEvent = () => {
setDialogOpen(true);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
setPrivateKey(fromByteArray(privateKey));
setPublicKey(fromByteArray(publicKey));
validateKey(
fromByteArray(privateKey),
privateKeyBitCount,
setPrivateKeyValidationText,
);
setDialogOpen(false);
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validateKey(privateKey, count, setPrivateKeyValidationText);
};
return (
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
bluetoothLoggingEnabled:
config.security?.bluetoothLoggingEnabled ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: privateKeyClickEvent,
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
},
},
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
},
},
],
},
{
label: "Admin Settings",
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
},
{
type: "text",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
inputChange: adminKeyInputChangeEvent,
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "bluetoothLoggingEnabled",
label: "Allow Bluetooth Logging",
description:
"Enables device (serial style logs) over Bluetooth",
},
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description: "Output live debug logging over serial",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
<PkiRegenerateDialog
open={dialogOpen}
onOpenChange={() => setDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
);
};
@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
@@ -1,5 +1,4 @@
import React, { useState } from "react"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js"; import { Label } from "@components/UI/Label.js";
@@ -9,6 +8,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js"; import { randId } from "@core/utils/randId.js";
import { HttpConnection } from "@meshtastic/js"; import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => { export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@@ -20,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({ }>({
defaultValues: { defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes( ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname window.location.hostname,
) )
? "meshtastic.local" ? "meshtastic.local"
: window.location.hostname, : window.location.hostname,
@@ -75,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Switch <Switch
// label="Use TLS" // label="Use TLS"
// description="Description" // description="Description"
disabled={location.protocol === "https:" || connectionInProgress} disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value} checked={value}
{...rest} {...rest}
/> />
@@ -84,7 +86,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
/> />
</div> </div>
<Button type="submit" disabled={connectionInProgress}> <Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? 'Connecting...' : 'Connect' }</span> <span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button> </Button>
</form> </form>
); );
@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
@@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return ( return (
<div className="flex w-full flex-col gap-2 p-4"> <div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => ( {serialPorts.map((port, index) => {
<Button const { usbProductId, usbVendorId } = port.getInfo();
key={index} return (
disabled={port.readable !== null} <Button
onClick={async () => { key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
await onConnect(port); disabled={port.readable !== null}
}} onClick={async () => {
> await onConnect(port);
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${ }}
port.getInfo().usbProductId ?? "UNK" >
}`} {`# ${index} - ${usbVendorId ?? "UNK"} - ${
</Button> usbProductId ?? "UNK"
))} }`}
</Button>
);
})}
{serialPorts.length === 0 && ( {serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono> <Mono className="m-auto select-none">No devices paired yet.</Mono>
)} )}
@@ -1,45 +1,74 @@
import { Subtle } from "@app/components/UI/Typography/Subtle.js"; import { Subtle } from "@app/components/UI/Typography/Subtle.js";
import { MessageWithState, useDevice } from "@app/core/stores/deviceStore.js"; import {
type MessageWithState,
useDevice,
} from "@app/core/stores/deviceStore.js";
import { Message } from "@components/PageComponents/Messages/Message.js"; import { Message } from "@components/PageComponents/Messages/Message.js";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";
import type { Types } from "@meshtastic/js"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.js";
import type { Protobuf, Types } from "@meshtastic/js";
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: MessageWithState[]; messages?: MessageWithState[];
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: Types.Destination; to: Types.Destination;
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
} }
export const ChannelChat = ({ export const ChannelChat = ({
messages, messages,
channel, channel,
to, to,
traceroutes,
}: ChannelChatProps): JSX.Element => { }: ChannelChatProps): JSX.Element => {
const { nodes } = useDevice(); const { nodes } = useDevice();
return ( return (
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
<div className="flex flex-grow flex-col"> <div className="flex flex-grow">
{messages ? ( <div className="flex flex-grow flex-col">
messages.map((message, index) => ( {messages ? (
<Message messages.map((message, index) => (
key={message.id} <Message
message={message} key={message.id}
lastMsgSameUser={ message={message}
index === 0 ? false : messages[index - 1].from === message.from lastMsgSameUser={
} index === 0
sender={nodes.get(message.from)} ? false
/> : messages[index - 1].from === message.from
)) }
) : ( sender={nodes.get(message.from)}
<div className="m-auto"> />
<InboxIcon className="m-auto" /> ))
<Subtle>No Messages</Subtle> ) : (
</div> <div className="m-auto">
)} <InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
</div>
<div
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
>
{to === "broadcast" ? null : traceroutes ? (
traceroutes.map((traceroute, index) => (
<TraceRoute
key={traceroute.id}
from={nodes.get(traceroute.from)}
to={nodes.get(traceroute.to)}
route={traceroute.data.route}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Traceroutes</Subtle>
</div>
)}
</div>
</div> </div>
<div className="p-3"> <div className="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} /> <MessageInput to={to} channel={channel} />
</div> </div>
</div> </div>
@@ -62,7 +62,7 @@ export const MessageInput = ({
<span className="w-full"> <span className="w-full">
<Input <Input
autoFocus={true} autoFocus={true}
minLength={2} minLength={1}
placeholder="Enter Message" placeholder="Enter Message"
value={messageDraft} value={messageDraft}
onChange={(e) => setMessageDraft(e.target.value)} onChange={(e) => setMessageDraft(e.target.value)}
@@ -0,0 +1,32 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { Protobuf } from "@meshtastic/js";
export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo;
route: Array<number>;
}
export const TraceRoute = ({
from,
to,
route,
}: TraceRouteProps): JSX.Element => {
const { nodes } = useDevice();
return route.length === 0 ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}↔{from?.user?.longName}
</span>
</div>
) : (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}↔
{route.map((hop) => `${nodes.get(hop)?.user?.longName ?? "Unknown"}↔`)}
{from?.user?.longName}
</span>
</div>
);
};
@@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
label: "Minimum Broadcast Seconds", label: "Minimum Broadcast Seconds",
description: description:
"The interval in seconds of how often we can send a message to the mesh when a state change is detected", "The interval in seconds of how often we can send a message to the mesh when a state change is detected",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => { export const MQTT = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const onSubmit = (data: MqttValidation) => { const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
new Protobuf.ModuleConfig.ModuleConfig({ new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: { payloadVariant: {
case: "mqtt", case: "mqtt",
value: data, value: {
...data,
mapReportSettings:
new Protobuf.ModuleConfig.ModuleConfig_MapReportSettings(
data.mapReportSettings,
),
},
}, },
}), }),
); );
@@ -70,7 +76,8 @@ export const MQTT = (): JSX.Element => {
type: "toggle", type: "toggle",
name: "encryptionEnabled", name: "encryptionEnabled",
label: "Encryption Enabled", label: "Encryption Enabled",
description: "Enable or disable MQTT encryption", description:
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -151,10 +158,39 @@ export const MQTT = (): JSX.Element => {
], ],
}, },
{ {
type: "number", type: "select",
name: "mapReportSettings.positionPrecision", name: "mapReportSettings.positionPrecision",
label: "Position Precision", label: "Approximate Location",
description: "Precision of the position", description:
"Position shared will be accurate within this distance",
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,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
type: "number", type: "number",
name: "paxcounterUpdateInterval", name: "paxcounterUpdateInterval",
label: "Update Interval (seconds)", label: "Update Interval (seconds)",
description: "How long to wait between sending paxcounter packets", description:
"How long to wait between sending paxcounter packets",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },
@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh", description: "How often to send Power data over the mesh",
}, },
{ {
type: "text", type: "toggle",
name: "powerScreenEnabled", name: "powerScreenEnabled",
label: "Power Screen Enabled", label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen", description: "Enable the Power Telemetry Screen",
+36 -32
View File
@@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js"; import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
@@ -18,40 +19,43 @@ export const PageLayout = ({
children, children,
}: PageLayoutProps): JSX.Element => { }: PageLayoutProps): JSX.Element => {
return ( return (
<div className="relative flex h-full w-full flex-col"> <>
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> <div className="relative flex h-full w-full flex-col">
<button <div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
type="button" <button
className="pl-4 transition-all hover:text-accent md:hidden" type="button"
> className="pl-4 transition-all hover:text-accent md:hidden"
<AlignLeftIcon /> >
</button> <AlignLeftIcon />
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> </button>
<div className="flex w-full items-center"> <div className="flex flex-1 items-center justify-between px-4 md:px-0">
<span className="w-full text-lg font-medium">{label}</span> <div className="flex w-full items-center">
<div className="flex justify-end space-x-4"> <span className="w-full text-lg font-medium">{label}</span>
{actions?.map((action, index) => ( <div className="flex justify-end space-x-4">
<button {actions?.map((action, index) => (
key={action.icon.name} <button
type="button" key={action.icon.name}
className="transition-all hover:text-accent" type="button"
onClick={action.onClick} className="transition-all hover:text-accent"
> onClick={action.onClick}
<action.icon /> >
</button> <action.icon />
))} </button>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
)}
>
{children}
<Footer />
</div>
</div> </div>
<div </>
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
); );
}; };
+15 -7
View File
@@ -4,15 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js"; import type { Page } from "@core/stores/deviceStore.js";
import { import {
BatteryMediumIcon,
CpuIcon,
EditIcon, EditIcon,
LayersIcon, LayersIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
ZapIcon, ZapIcon,
BatteryMediumIcon
} from "lucide-react"; } from "lucide-react";
export interface SidebarProps { export interface SidebarProps {
@@ -20,8 +21,9 @@ export interface SidebarProps {
} }
export const Sidebar = ({ children }: SidebarProps): JSX.Element => { export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice(); const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum); const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice(); const { activePage, setActivePage, setDialogOpen } = useDevice();
interface NavLink { interface NavLink {
@@ -77,12 +79,18 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
</div> </div>
<div className="px-8 pb-6"> <div className="px-8 pb-6">
<div className="flex items-center"> <div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/> <BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle> <Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<ZapIcon size={24} viewBox={'0 0 36 24'}/> <ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle> <Subtle>
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div> </div>
</div> </div>
+8 -2
View File
@@ -17,8 +17,14 @@ export function Toaster() {
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>} {title && (
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>} <ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />
+3 -1
View File
@@ -1,4 +1,4 @@
import { VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.js"; import { cn } from "@core/utils/cn.js";
@@ -12,6 +12,8 @@ const buttonVariants = cva(
"bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
destructive: destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
subtle: subtle:
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
return (
<footer
className={`flex flex- justify-center p-2 ${className}`}
style={{
backgroundColor: "var(--backgroundPrimary)",
color: "var(--textPrimary)",
}}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Powered by ▲ Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Legal Information
</a>
</p>
</footer>
);
},
);
export default Footer;
+111
View File
@@ -0,0 +1,111 @@
import * as React from "react";
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.js";
import type { LucideIcon } from "lucide-react";
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
hide?: boolean;
devicePSKBitCount?: number;
value: string;
variant: "default" | "invalid";
buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
action?: {
icon: LucideIcon;
onClick: () => void;
};
disabled?: boolean;
}
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
(
{
hide = true,
devicePSKBitCount,
variant,
value,
buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange,
inputChange,
buttonClick,
action,
disabled,
...props
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
type={hide ? "password" : "text"}
id="pskInput"
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="success"
onClick={buttonClick}
disabled={disabled}
{...props}
>
{buttonText}
</Button>
</>
);
},
);
Generator.displayName = "Button";
export { Generator };
+22 -4
View File
@@ -1,10 +1,27 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.js"; import { cn } from "@core/utils/cn.js";
import { type VariantProps, cva } from "class-variance-authority";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
const inputVariants = cva(
"flex h-10 w-full rounded-md border bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-700",
invalid: "border-red-500 dark:border-red-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
action?: { action?: {
@@ -14,7 +31,7 @@ export interface InputProps
} }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, prefix, suffix, action, ...props }, ref) => { ({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{prefix && ( {prefix && (
@@ -24,10 +41,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)} )}
<input <input
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
action && "pr-8", action && "pr-8",
className, className,
inputVariants({ variant }),
)} )}
value={value}
ref={ref} ref={ref}
{...props} {...props}
/> />
@@ -51,4 +69,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
); );
Input.displayName = "Input"; Input.displayName = "Input";
export { Input }; export { Input, inputVariants };
+1 -1
View File
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800", "inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -1,5 +1,5 @@
import * as ToastPrimitives from "@radix-ui/react-toast"; import * as ToastPrimitives from "@radix-ui/react-toast";
import { VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import * as React from "react"; import * as React from "react";
+58 -4
View File
@@ -1,4 +1,5 @@
import { ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import React, { useState } from "react";
export interface TableProps { export interface TableProps {
headings: Heading[]; headings: Heading[];
@@ -12,6 +13,49 @@ export interface Heading {
} }
export const Table = ({ headings, rows }: TableProps): JSX.Element => { export const Table = ({ headings, rows }: TableProps): JSX.Element => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const headingSort = (title: string) => {
if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortColumn(title);
setSortOrder("asc");
}
};
const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0;
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
const aValue = a[columnIndex].props.children;
const bValue = b[columnIndex].props.children;
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0;
if (aTimestamp < bTimestamp) {
return sortOrder === "asc" ? -1 : 1;
}
if (aTimestamp > bTimestamp) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
});
return ( return (
<table className="min-w-full"> <table className="min-w-full">
<thead className="bg-backgroundPrimary text-sm font-semibold text-textPrimary"> <thead className="bg-backgroundPrimary text-sm font-semibold text-textPrimary">
@@ -25,11 +69,19 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
? "cursor-pointer hover:brightness-hover active:brightness-press" ? "cursor-pointer hover:brightness-hover active:brightness-press"
: "" : ""
}`} }`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
> >
<div className="flex gap-2"> <div className="flex gap-2">
{heading.title} {heading.title}
{heading.sortable && ( {sortColumn === heading.title && (
<ChevronUpIcon size={16} className="my-auto" /> <>
{sortOrder === "asc" ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</>
)} )}
</div> </div>
</th> </th>
@@ -37,10 +89,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => ( {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}>
{row.map((item, index) => ( {row.map((item, index) => (
<td <td
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
key={index} key={index}
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2" className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
> >
+20 -16
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useState } from "react"; import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js"; import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js";
@@ -92,9 +92,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) { if (toastId) {
addToRemoveQueue(toastId); addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { for (const toast of state.toasts) {
addToRemoveQueue(toast.id); addToRemoveQueue(toast.id);
}); }
} }
return { return {
@@ -130,9 +130,9 @@ let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action); memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { for (const listener of listeners) {
listener(memoryState); listener(memoryState);
}); }
} }
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, "id">;
@@ -166,18 +166,22 @@ function toast({ ...props }: Toast) {
}; };
} }
function useToast() { const subscribe = (listener: () => void) => {
const [state, setState] = useState<State>(memoryState); listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
useEffect(() => { const getState = () => {
listeners.push(setState); return memoryState;
return () => { };
const index = listeners.indexOf(setState);
if (index > -1) { function useToast() {
listeners.splice(index, 1); const state = useSyncExternalStore(subscribe, getState);
}
};
}, [state]);
return { return {
...state, ...state,
+6 -3
View File
@@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages", currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
darkMode: (localStorage.getItem('theme-dark') !== null ? (localStorage.getItem('theme-dark') === 'true' ? true : false) : window.matchMedia("(prefers-color-scheme: dark)").matches), darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches,
accent: "orange", accent: "orange",
connectDialogOpen: false, connectDialogOpen: false,
nodeNumToBeRemoved: 0, nodeNumToBeRemoved: 0,
@@ -96,7 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
); );
}, },
setDarkMode: (enabled: boolean) => { setDarkMode: (enabled: boolean) => {
localStorage.setItem('theme-dark', enabled.toString()); localStorage.setItem("theme-dark", enabled.toString());
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {
draft.darkMode = enabled; draft.darkMode = enabled;
@@ -105,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
}, },
setNodeNumToBeRemoved: (nodeNum) => setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({ set((state) => ({
nodeNumToBeRemoved: nodeNum nodeNumToBeRemoved: nodeNum,
})), })),
setAccent(color) { setAccent(color) {
set( set(
+34 -2
View File
@@ -42,6 +42,10 @@ export interface Device {
direct: Map<number, MessageWithState[]>; direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>; broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
}; };
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType; connection?: Types.ConnectionType;
activePage: Page; activePage: Page;
activeNode: number; activeNode: number;
@@ -75,6 +79,9 @@ export interface Device {
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void; addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void; addConnection: (connection: Types.ConnectionType) => void;
addMessage: (message: MessageWithState) => void; addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void; removeNode: (nodeNum: number) => void;
setMessageState: ( setMessageState: (
@@ -122,6 +129,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
direct: new Map(), direct: new Map(),
broadcast: new Map(), broadcast: new Map(),
}, },
traceroutes: new Map(),
connection: undefined, connection: undefined,
activePage: "messages", activePage: "messages",
activeNode: 0, activeNode: 0,
@@ -183,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
device.config.bluetooth = config.payloadVariant.value; device.config.bluetooth = config.payloadVariant.value;
break; break;
} }
case "security": {
device.config.security = config.payloadVariant.value;
}
} }
} }
}), }),
@@ -487,6 +498,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}), }),
); );
}, },
addMetadata: (from, metadata) => { addMetadata: (from, metadata) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@@ -498,6 +510,26 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}), }),
); );
}, },
addTraceRoute: (traceroute) => {
set(
produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
}
const nodetraceroutes = device.traceroutes.get(traceroute.from);
if (nodetraceroutes) {
nodetraceroutes.push(traceroute);
device.traceroutes.set(traceroute.from, nodetraceroutes);
} else {
device.traceroutes.set(traceroute.from, [traceroute]);
}
}),
);
},
removeNode: (nodeNum) => { removeNode: (nodeNum) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@@ -506,8 +538,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
return; return;
} }
device.nodes.delete(nodeNum); device.nodes.delete(nodeNum);
}) }),
) );
}, },
setMessageState: ( setMessageState: (
type: "direct" | "broadcast", type: "direct" | "broadcast",
+7 -1
View File
@@ -1,5 +1,5 @@
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
@@ -86,6 +86,12 @@ export const subscribeAll = (
}); });
}); });
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
device.addTraceRoute({
...traceRoutePacket,
});
});
connection.events.onPendingSettingsChange.subscribe((state) => { connection.events.onPendingSettingsChange.subscribe((state) => {
device.setPendingSettingsChanges(state); device.setPendingSettingsChanges(state);
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
+15
View File
@@ -0,0 +1,15 @@
import { x25519 } from "@noble/curves/ed25519";
export function getX25519PrivateKey(): Uint8Array {
const key = x25519.utils.randomPrivateKey();
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
return key;
}
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
return x25519.getPublicKey(privateKey);
}
+6 -4
View File
@@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Network } from "@components/PageComponents/Config/Network.js"; import { Network } from "@components/PageComponents/Config/Network.js";
import { Position } from "@components/PageComponents/Config/Position.js"; import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js"; import { Power } from "@components/PageComponents/Config/Power.js";
import { Security } from "@components/PageComponents/Config/Security.js";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -47,16 +48,17 @@ export const DeviceConfig = (): JSX.Element => {
label: "Bluetooth", label: "Bluetooth",
element: Bluetooth, element: Bluetooth,
}, },
{
label: "Security",
element: Security,
},
]; ];
return ( return (
<Tabs defaultValue="Device"> <Tabs defaultValue="Device">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger key={tab.label} value={tab.label}>
key={tab.label}
value={tab.label}
>
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}
+1 -1
View File
@@ -5,11 +5,11 @@ import { Audio } from "@components/PageComponents/ModuleConfig/Audio.js";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js"; import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js"; import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js"; import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js"; import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js"; import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js"; import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
+74 -72
View File
@@ -21,79 +21,81 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]); const devices = useMemo(() => getDevices(), [getDevices]);
return ( return (
<div className="flex flex-col gap-3 p-3"> <>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 p-3">
<div className="space-y-1"> <div className="flex items-center justify-between">
<H3>Connected Devices</H3> <div className="space-y-1">
<Subtle>Manage, connect and disconnect devices</Subtle> <H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div> </div>
</div> </div>
</>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div>
</div>
); );
}; };
+23 -21
View File
@@ -14,7 +14,7 @@ import {
ZoomInIcon, ZoomInIcon,
ZoomOutIcon, ZoomOutIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Marker, useMap } from "react-map-gl"; import { Marker, useMap } from "react-map-gl";
import MapGl from "react-map-gl/maplibre"; import MapGl from "react-map-gl/maplibre";
@@ -27,7 +27,7 @@ export const MapPage = (): JSX.Element => {
const allNodes = Array.from(nodes.values()); const allNodes = Array.from(nodes.values());
const getBBox = () => { const getBBox = useCallback(() => {
if (!map) { if (!map) {
return; return;
} }
@@ -64,7 +64,7 @@ export const MapPage = (): JSX.Element => {
if (center) { if (center) {
map.easeTo(center); map.easeTo(center);
} }
}; }, [allNodes, map]);
useEffect(() => { useEffect(() => {
map?.on("zoom", () => { map?.on("zoom", () => {
@@ -128,7 +128,11 @@ export const MapPage = (): JSX.Element => {
attributionControl={false} attributionControl={false}
renderWorldCopies={false} renderWorldCopies={false}
maxPitch={0} maxPitch={0}
style = {{filter: darkMode ? 'brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)' : ''}} style={{
filter: darkMode
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
: "",
}}
dragRotate={false} dragRotate={false}
touchZoomRotate={false} touchZoomRotate={false}
initialViewState={{ initialViewState={{
@@ -140,8 +144,8 @@ export const MapPage = (): JSX.Element => {
{waypoints.map((wp) => ( {waypoints.map((wp) => (
<Marker <Marker
key={wp.id} key={wp.id}
longitude={wp.longitudeI / 1e7} longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={wp.latitudeI / 1e7} latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom" anchor="bottom"
> >
<div> <div>
@@ -159,23 +163,21 @@ export const MapPage = (): JSX.Element => {
return ( return (
<Marker <Marker
key={node.num} key={node.num}
longitude={node.position.longitudeI / 1e7} longitude={(node.position.longitudeI ?? 0) / 1e7}
latitude={node.position.latitudeI / 1e7} latitude={(node.position.latitudeI ?? 0) / 1e7}
style = {{filter: darkMode ? 'invert(1)' : ''}} style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom" anchor="bottom"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
> >
<div <div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<Hashicon value={node.num.toString()} size={22} /> <Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}> <Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName} {node.user?.longName}
+61 -34
View File
@@ -3,15 +3,17 @@ import { PageLayout } from "@components/PageLayout.js";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { getChannelName } from "@pages/Channels.js"; import { getChannelName } from "@pages/Channels.js";
import { HashIcon } from "lucide-react"; import { HashIcon, WaypointsIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export const MessagesPage = (): JSX.Element => { export const MessagesPage = (): JSX.Element => {
const { channels, nodes, hardware, messages } = useDevice(); const { channels, nodes, hardware, messages, traceroutes, connection } =
useDevice();
const [chatType, setChatType] = const [chatType, setChatType] =
useState<Types.PacketDestination>("broadcast"); useState<Types.PacketDestination>("broadcast");
const [activeChat, setActiveChat] = useState<number>( const [activeChat, setActiveChat] = useState<number>(
@@ -25,6 +27,7 @@ export const MessagesPage = (): JSX.Element => {
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
); );
const currentChannel = channels.get(activeChat); const currentChannel = channels.get(activeChat);
const { toast } = useToast();
return ( return (
<> <>
@@ -64,38 +67,62 @@ export const MessagesPage = (): JSX.Element => {
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<PageLayout <div className="flex flex-col flex-grow">
label={`Messages: ${ <PageLayout
chatType === "broadcast" && currentChannel label={`Messages: ${
? getChannelName(currentChannel) chatType === "broadcast" && currentChannel
: chatType === "direct" && nodes.get(activeChat) ? getChannelName(currentChannel)
? nodes.get(activeChat)?.user?.longName ?? "Unknown" : chatType === "direct" && nodes.get(activeChat)
: "Loading..." ? nodes.get(activeChat)?.user?.longName ?? "Unknown"
}`} : "Loading..."
> }`}
{allChannels.map( actions={
(channel) => chatType === "direct"
activeChat === channel.index && ( ? [
<ChannelChat {
key={channel.index} icon: WaypointsIcon,
to="broadcast" async onClick() {
messages={messages.broadcast.get(channel.index)} const targetNode = nodes.get(activeChat)?.num;
channel={channel.index} if (targetNode === undefined) return;
/> toast({
), title: "Sending Traceroute, please wait...",
)} });
{filteredNodes.map( await connection?.traceRoute(targetNode).then(() =>
(node) => toast({
activeChat === node.num && ( title: "Traceroute sent.",
<ChannelChat }),
key={node.num} );
to={activeChat} },
messages={messages.direct.get(node.num)} },
channel={Types.ChannelNumber.Primary} ]
/> : []
), }
)} >
</PageLayout> {allChannels.map(
(channel) =>
activeChat === channel.index && (
<ChannelChat
key={channel.index}
to="broadcast"
messages={messages.broadcast.get(channel.index)}
channel={channel.index}
/>
),
)}
{filteredNodes.map(
(node) =>
activeChat === node.num && (
<ChannelChat
key={node.num}
to={activeChat}
messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary}
traceroutes={traceroutes.get(node.num)}
/>
),
)}
</PageLayout>
</div>
</> </>
); );
}; };
+74 -56
View File
@@ -1,15 +1,16 @@
import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Table } from "@components/generic/Table/index.js"; import { Table } from "@components/generic/Table/index.js";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js"; import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { base16 } from "rfc4648";
import { Button } from "@components/UI/Button.js";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useAppStore } from "@app/core/stores/appStore"; import { Fragment } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@@ -27,59 +28,76 @@ export const NodesPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar /> <Sidebar />
<div className="w-full overflow-y-auto"> <div className="flex flex-col w-full">
<Table <div className="overflow-y-auto h-full">
headings={[ <Table
{ title: "", type: "blank", sortable: false }, headings={[
{ title: "Name", type: "normal", sortable: true }, { title: "", type: "blank", sortable: false },
{ title: "Model", type: "normal", sortable: true }, { title: "Name", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true }, { title: "Model", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "MAC Address", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false }, { title: "Connection", type: "normal", sortable: true },
]} { title: "Remove", type: "normal", sortable: false },
rows={filteredNodes.map((node) => [ ]}
<Hashicon size={24} value={node.num.toString()} />, rows={filteredNodes.map((node) => [
<h1> <Hashicon key="icon" size={24} value={node.num.toString()} />,
{node.user?.longName ?? <h1 key="header">
(node.user?.macaddr {node.user?.longName ??
? `Meshtastic ${base16 (node.user?.macaddr
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) ? `Meshtastic ${base16
.toLowerCase()}` .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
: `UNK: ${node.num}`)} .toLowerCase()}`
</h1>, : `UNK: ${node.num}`)}
</h1>,
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>, <Mono key="model">
<Mono> {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
{base16 </Mono>,
.stringify(node.user?.macaddr ?? []) <Mono key="addr">
.match(/.{1,2}/g) {base16
?.join(":") ?? "UNK"} .stringify(node.user?.macaddr ?? [])
</Mono>, .match(/.{1,2}/g)
node.lastHeard === 0 ? ( ?.join(":") ?? "UNK"}
<p>Never</p> </Mono>,
) : ( <Fragment key="lastHeard">
<TimeAgo timestamp={node.lastHeard * 1000} /> {node.lastHeard === 0 ? (
), <p>Never</p>
<Mono> ) : (
{node.snr}db/ <TimeAgo timestamp={node.lastHeard * 1000} />
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ )}
{(node.snr + 10) * 5}raw </Fragment>,
</Mono>, <Mono key="snr">
<Mono> {node.snr}db/
{node.lastHeard != 0 ? {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
(node.viaMqtt === false && node.hopsAway === 0 {(node.snr + 10) * 5}raw
? "Direct": node.hopsAway.toString() + " hops away") </Mono>,
: "-"} <Mono key="hops">
{node.viaMqtt === true? ", via MQTT": ""} {node.lastHeard !== 0
</Mono>, ? node.viaMqtt === false && node.hopsAway === 0
<Button variant="destructive" onClick={() => { ? "Direct"
setNodeNumToBeRemoved(node.num); : `${node.hopsAway.toString()} ${
setDialogOpen("nodeRemoval", true) node.hopsAway > 1 ? "hops" : "hop"
}}><TrashIcon />Remove</Button> } away`
])} : "-"}
/> {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])}
/>
</div>
<Footer />
</div> </div>
</> </>
); );
+5 -1
View File
@@ -3,7 +3,11 @@ import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class BluetoothValidation export class BluetoothValidation
implements Omit<Protobuf.Config.Config_BluetoothConfig, keyof Message> implements
Omit<
Protobuf.Config.Config_BluetoothConfig,
keyof Message | "deviceLoggingEnabled"
>
{ {
@IsBoolean() @IsBoolean()
enabled: boolean; enabled: boolean;
+7 -1
View File
@@ -1,6 +1,6 @@
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
export class DeviceValidation export class DeviceValidation
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message>
@@ -34,4 +34,10 @@ export class DeviceValidation
@IsBoolean() @IsBoolean()
disableTripleClick: boolean; disableTripleClick: boolean;
@IsBoolean()
ledHeartbeatDisabled: boolean;
@IsString()
tzdef: string;
} }
+3
View File
@@ -34,4 +34,7 @@ export class DisplayValidation
@IsBoolean() @IsBoolean()
wakeOnTapOrMotion: boolean; wakeOnTapOrMotion: boolean;
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation)
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation;
} }
+2 -1
View File
@@ -3,7 +3,8 @@ import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
export class LoRaValidation export class LoRaValidation
implements Omit<Protobuf.Config.Config_LoRaConfig, keyof Message> implements
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled">
{ {
@IsBoolean() @IsBoolean()
usePreset: boolean; usePreset: boolean;
+6 -2
View File
@@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime']; const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
export class PositionValidation export class PositionValidation
implements Omit<Protobuf.Config.Config_PositionConfig, keyof Message | typeof DeprecatedPositionValidationFields[number]> implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
>
{ {
@IsInt() @IsInt()
positionBroadcastSecs: number; positionBroadcastSecs: number;
+2 -1
View File
@@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator"; import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
export class PowerValidation export class PowerValidation
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message> implements
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
{ {
@IsBoolean() @IsBoolean()
isPowerSaving: boolean; isPowerSaving: boolean;
+35
View File
@@ -0,0 +1,35 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString } from "class-validator";
export class SecurityValidation
implements
Omit<
Protobuf.Config.Config_SecurityConfig,
keyof Message | "adminKey" | "privateKey" | "publicKey"
>
{
@IsBoolean()
adminChannelEnabled: boolean;
@IsString()
adminKey: string;
@IsBoolean()
bluetoothLoggingEnabled: boolean;
@IsBoolean()
debugLogApiEnabled: boolean;
@IsBoolean()
isManaged: boolean;
@IsString()
privateKey: string;
@IsString()
publicKey: string;
@IsBoolean()
serialEnabled: boolean;
}
+9 -1
View File
@@ -1,6 +1,12 @@
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js"; import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString, Length, IsNumber } from "class-validator"; import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
export class MqttValidation export class MqttValidation
implements implements
@@ -47,8 +53,10 @@ export class MqttValidationMapReportSettings
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message>
{ {
@IsNumber() @IsNumber()
@IsOptional()
publishIntervalSecs: number; publishIntervalSecs: number;
@IsNumber() @IsNumber()
@IsOptional()
positionPrecision: number; positionPrecision: number;
} }
@@ -11,4 +11,10 @@ export class PaxcounterValidation
@IsInt() @IsInt()
paxcounterUpdateInterval: number; paxcounterUpdateInterval: number;
@IsInt()
bleThreshold: number;
@IsInt()
wifiThreshold: number;
} }
+4 -1
View File
@@ -4,7 +4,10 @@ import { IsBoolean, IsInt } from "class-validator";
export class StoreForwardValidation export class StoreForwardValidation
implements implements
Omit<Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, keyof Message> Omit<
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
keyof Message | "isServer"
>
{ {
@IsBoolean() @IsBoolean()
enabled: boolean; enabled: boolean;
+1 -5
View File
@@ -1,5 +1 @@
{ { "github": { "silent": true } }
"github": {
"silent": true
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { execSync } from "child_process"; import { execSync } from "node:child_process";
import { resolve } from "path"; import { resolve } from "node:path";
import { visualizer } from "rollup-plugin-visualizer"; import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment"; import EnvironmentPlugin from "vite-plugin-environment";