Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921db10d91 | ||
|
|
bf4f593e3a | ||
|
|
1e061a1e19 | ||
|
|
9b9f537e2c | ||
|
|
985cce0b0d | ||
|
|
3fe38eb506 | ||
|
|
51081d3052 | ||
|
|
c08f6d16bb | ||
|
|
62ad4c49f8 | ||
|
|
3b0a1e6108 | ||
|
|
c2f2205626 | ||
|
|
87c729d694 | ||
|
|
8e4f60edf3 | ||
|
|
8811eee9f5 | ||
|
|
2af93f1acd | ||
|
|
78a35544c7 | ||
|
|
3ad2d650b0 | ||
|
|
2e12b27566 | ||
|
|
989fad7e17 | ||
|
|
f7a2e5f76b | ||
|
|
2b81fc47e2 | ||
|
|
b522113cd7 | ||
|
|
fce642c24e | ||
|
|
bf425a8ec7 | ||
|
|
a7d0d36086 | ||
|
|
5e72510bef | ||
|
|
88efdc4758 | ||
|
|
530d33d1e4 | ||
|
|
6375187d50 | ||
|
|
300fb5c3e5 | ||
|
|
0dcc2b1975 | ||
|
|
41acc4d25d | ||
|
|
d10c010b9a | ||
|
|
80ab44c8db | ||
|
|
7895df2d9f | ||
|
|
c780437355 | ||
|
|
9d4aa05316 | ||
|
|
354d04592b | ||
|
|
9bea6870bb | ||
|
|
d0bd02980d | ||
|
|
7d1135b9dc | ||
|
|
f66332b3e3 | ||
|
|
05a6b6293e | ||
|
|
b9a8a2ba6c | ||
|
|
c16070f02b | ||
|
|
3e6653a98f | ||
|
|
59126ca939 | ||
|
|
4bde402a53 | ||
|
|
4cf91272de | ||
|
|
c711c39aa9 | ||
|
|
c4342f1a2b | ||
|
|
afc45588fa | ||
|
|
22cd5aa88d | ||
|
|
8c4c8a760e | ||
|
|
0911df6b0d | ||
|
|
06c20fa950 | ||
|
|
cd0056783c | ||
|
|
b1baf2d8e6 | ||
|
|
2d106eb3a9 | ||
|
|
22d900a831 | ||
|
|
c6cc5e5e6f | ||
|
|
f0d8db1c87 | ||
|
|
8c37be4af3 | ||
|
|
076dae80b7 | ||
|
|
7d4001ea9d | ||
|
|
4215eb1a55 | ||
|
|
e3fad3015f | ||
|
|
02a63c213e | ||
|
|
737fbb4320 | ||
|
|
66fb300575 | ||
|
|
cf423620c4 | ||
|
|
8bb0a96744 | ||
|
|
65247c4f35 | ||
|
|
af51659e71 | ||
|
|
f0eae444c7 | ||
|
|
6d3bf39b76 | ||
|
|
7d5950d6cc | ||
|
|
d3836a7250 | ||
|
|
f64b96527e | ||
|
|
be9169f56f | ||
|
|
f6be57224e | ||
|
|
a8dcab0844 | ||
|
|
049f3de919 | ||
|
|
6c1f140ad1 | ||
|
|
4c4be2e18f | ||
|
|
2b34d78a86 | ||
|
|
54a7b88146 | ||
|
|
e7892fd6a0 | ||
|
|
1eedb6d97b | ||
|
|
faf094084c | ||
|
|
6bbe995ee5 | ||
|
|
32acd23362 | ||
|
|
cd0fcbbf90 | ||
|
|
cfc2ea0fe5 | ||
|
|
5771e1b733 | ||
|
|
1c7c44a472 | ||
|
|
9c6aff534a | ||
|
|
38b7e600b1 | ||
|
|
1ae879342a | ||
|
|
2528391814 | ||
|
|
d6147f5b7f | ||
|
|
8c17a8be38 | ||
|
|
a4e2e7eec1 | ||
|
|
4653656420 | ||
|
|
d42e8c10a0 | ||
|
|
0955bbe24b | ||
|
|
f3fbe75c66 | ||
|
|
bcebfd211b | ||
|
|
5bd385b535 | ||
|
|
9f5604971b | ||
|
|
93d9f721b4 | ||
|
|
569bb09f94 | ||
|
|
2566395168 | ||
|
|
8893a196c2 | ||
|
|
fdbcdd955b | ||
|
|
723c9ee5d8 | ||
|
|
22c862fd08 | ||
|
|
c47aeb652b | ||
|
|
2f37390985 | ||
|
|
205c9a21e3 | ||
|
|
29fab4b6f3 | ||
|
|
0dddf6ad2f | ||
|
|
b24c9312c4 | ||
|
|
268ad4908a | ||
|
|
fa127eda33 | ||
|
|
8942468c2c | ||
|
|
3aedc7b7c2 | ||
|
|
401a594ec7 | ||
|
|
a5a37cd4ab | ||
|
|
283f548136 | ||
|
|
93b139c268 | ||
|
|
9e04c1f486 | ||
|
|
6b268b79ed | ||
|
|
11fe2fdb35 | ||
|
|
69f67e3657 | ||
|
|
4143249c55 | ||
|
|
4c1737bc44 | ||
|
|
17c44054be | ||
|
|
026256ac5c | ||
|
|
e9a681ab21 | ||
|
|
bcac95e7ed | ||
|
|
daff97a5e0 | ||
|
|
452f2581e2 | ||
|
|
569c2daa09 | ||
|
|
e825a737b0 | ||
|
|
54c983439c | ||
|
|
7b6b8daeba | ||
|
|
0f89e04bb0 | ||
|
|
a728b848e1 | ||
|
|
f16cce90c8 | ||
|
|
056a194ede | ||
|
|
be3117651c | ||
|
|
86263990e6 | ||
|
|
390b16812a | ||
|
|
1458497fc3 | ||
|
|
97b9570196 | ||
|
|
82c1d3e3f1 | ||
|
|
a7237bcb2b | ||
|
|
6e145421ef | ||
|
|
98f8965aed |
@@ -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 }}"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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: Archive compressed build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: dist/build.tar
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Buildah Build
|
||||||
|
id: build-container
|
||||||
|
uses: redhat-actions/buildah-build@v2
|
||||||
|
with:
|
||||||
|
containerfiles: |
|
||||||
|
./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 }}"
|
||||||
Vendored
+3
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"quickfix.biome": "explicit"
|
||||||
|
},
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
}
|
}
|
||||||
+4
-3
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-35
@@ -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,67 @@
|
|||||||
},
|
},
|
||||||
"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-5",
|
||||||
"@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": "^3.6.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",
|
||||||
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"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-20240906232734-3da561588c55.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4912
-3069
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {}
|
autoprefixer: {},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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 => {
|
||||||
|
|||||||
@@ -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), "=")
|
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, "+")
|
||||||
.replace(/_/g, "/");
|
.replace(/_/g, "/");
|
||||||
try {
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,14 +100,22 @@ 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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
<DynamicFormField
|
|
||||||
key={field.label}
|
key={field.label}
|
||||||
|
label={field.label}
|
||||||
|
description={field.description}
|
||||||
|
valid={
|
||||||
|
field.validationText === undefined ||
|
||||||
|
field.validationText === ""
|
||||||
|
}
|
||||||
|
validationText={field.validationText}
|
||||||
|
>
|
||||||
|
<DynamicFormField
|
||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={isDisabled(field.disabledBy)}
|
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,6 +56,13 @@ export const LoRa = (): JSX.Element => {
|
|||||||
label: "Ignore MQTT",
|
label: "Ignore MQTT",
|
||||||
description: "Don't forward MQTT messages over the mesh",
|
description: "Don't forward MQTT messages over the mesh",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "configOkToMqtt",
|
||||||
|
label: "OK to MQTT",
|
||||||
|
description:
|
||||||
|
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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[0] ?? 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,
|
||||||
|
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: "debugLogApiEnabled",
|
||||||
|
label: "Enable Debug Log API",
|
||||||
|
description:
|
||||||
|
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => {
|
||||||
|
const { usbProductId, usbVendorId } = port.getInfo();
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={index}
|
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
|
||||||
disabled={port.readable !== null}
|
disabled={port.readable !== null}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await onConnect(port);
|
await onConnect(port);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${
|
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
|
||||||
port.getInfo().usbProductId ?? "UNK"
|
usbProductId ?? "UNK"
|
||||||
}`}
|
}`}
|
||||||
</Button>
|
</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,25 +1,32 @@
|
|||||||
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">
|
||||||
<div className="flex flex-grow flex-col">
|
<div className="flex flex-grow flex-col">
|
||||||
{messages ? (
|
{messages ? (
|
||||||
messages.map((message, index) => (
|
messages.map((message, index) => (
|
||||||
@@ -27,7 +34,9 @@ export const ChannelChat = ({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
lastMsgSameUser={
|
lastMsgSameUser={
|
||||||
index === 0 ? false : messages[index - 1].from === message.from
|
index === 0
|
||||||
|
? false
|
||||||
|
: messages[index - 1].from === message.from
|
||||||
}
|
}
|
||||||
sender={nodes.get(message.from)}
|
sender={nodes.get(message.from)}
|
||||||
/>
|
/>
|
||||||
@@ -39,7 +48,27 @@ export const ChannelChat = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<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 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +8,7 @@ export interface PageLayoutProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
actions?: {
|
actions?: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
iconClasses?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
@@ -18,6 +20,7 @@ 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="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="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
||||||
<button
|
<button
|
||||||
@@ -37,7 +40,7 @@ export const PageLayout = ({
|
|||||||
className="transition-all hover:text-accent"
|
className="transition-all hover:text-accent"
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
>
|
>
|
||||||
<action.icon />
|
<action.icon className={action.iconClasses} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,11 +50,13 @@ export const PageLayout = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-y-auto",
|
"flex h-full w-full flex-col overflow-y-auto",
|
||||||
!noPadding && "p-3",
|
!noPadding && "pl-3 pr-3 ",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
+17
-13
@@ -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() {
|
||||||
useEffect(() => {
|
const index = listeners.indexOf(listener);
|
||||||
listeners.push(setState);
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState);
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listeners.splice(index, 1);
|
listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [state]);
|
};
|
||||||
|
|
||||||
|
const getState = () => {
|
||||||
|
return memoryState;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const state = useSyncExternalStore(subscribe, getState);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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[]) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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 flex-col gap-3 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -83,7 +84,7 @@ export const Dashboard = () => {
|
|||||||
<div className="m-auto flex flex-col gap-3 text-center">
|
<div className="m-auto flex flex-col gap-3 text-center">
|
||||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||||
<H3>No Devices</H3>
|
<H3>No Devices</H3>
|
||||||
<Subtle>Connect atleast one device to get started</Subtle>
|
<Subtle>Connect at least one device to get started</Subtle>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => setConnectDialogOpen(true)}
|
onClick={() => setConnectDialogOpen(true)}
|
||||||
@@ -95,5 +96,6 @@ export const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-12
@@ -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,13 +163,10 @@ 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"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
map?.easeTo({
|
map?.easeTo({
|
||||||
zoom: 12,
|
zoom: 12,
|
||||||
@@ -176,6 +177,7 @@ export const MapPage = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||||
<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}
|
||||||
|
|||||||
+46
-2
@@ -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, LockIcon, LockOpenIcon, 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,6 +67,7 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
))}
|
))}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
<div className="flex flex-col flex-grow">
|
||||||
<PageLayout
|
<PageLayout
|
||||||
label={`Messages: ${
|
label={`Messages: ${
|
||||||
chatType === "broadcast" && currentChannel
|
chatType === "broadcast" && currentChannel
|
||||||
@@ -72,6 +76,44 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
||||||
: "Loading..."
|
: "Loading..."
|
||||||
}`}
|
}`}
|
||||||
|
actions={
|
||||||
|
chatType === "direct"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
|
? LockIcon
|
||||||
|
: LockOpenIcon,
|
||||||
|
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-yellow-300",
|
||||||
|
async onClick() {
|
||||||
|
const targetNode = nodes.get(activeChat)?.num;
|
||||||
|
if (targetNode === undefined) return;
|
||||||
|
toast({
|
||||||
|
title: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
|
? "Chat is using PKI encryption."
|
||||||
|
: "Chat is using PSK encryption.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: WaypointsIcon,
|
||||||
|
async onClick() {
|
||||||
|
const targetNode = nodes.get(activeChat)?.num;
|
||||||
|
if (targetNode === undefined) return;
|
||||||
|
toast({
|
||||||
|
title: "Sending Traceroute, please wait...",
|
||||||
|
});
|
||||||
|
await connection?.traceRoute(targetNode).then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Traceroute sent.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{allChannels.map(
|
{allChannels.map(
|
||||||
(channel) =>
|
(channel) =>
|
||||||
@@ -92,10 +134,12 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
to={activeChat}
|
to={activeChat}
|
||||||
messages={messages.direct.get(node.num)}
|
messages={messages.direct.get(node.num)}
|
||||||
channel={Types.ChannelNumber.Primary}
|
channel={Types.ChannelNumber.Primary}
|
||||||
|
traceroutes={traceroutes.get(node.num)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+47
-21
@@ -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 { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { Fragment } from "react";
|
||||||
import { base16 } from "rfc4648";
|
import { base16 } from "rfc4648";
|
||||||
import { Button } from "@components/UI/Button.js";
|
|
||||||
import { TrashIcon } from "lucide-react";
|
|
||||||
import { useAppStore } from "@app/core/stores/appStore";
|
|
||||||
|
|
||||||
|
|
||||||
export interface DeleteNoteDialogProps {
|
export interface DeleteNoteDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,7 +28,8 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="w-full overflow-y-auto">
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="overflow-y-auto h-full">
|
||||||
<Table
|
<Table
|
||||||
headings={[
|
headings={[
|
||||||
{ title: "", type: "blank", sortable: false },
|
{ title: "", type: "blank", sortable: false },
|
||||||
@@ -36,12 +38,13 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
{ title: "MAC Address", type: "normal", sortable: true },
|
{ title: "MAC Address", type: "normal", sortable: true },
|
||||||
{ title: "Last Heard", type: "normal", sortable: true },
|
{ title: "Last Heard", type: "normal", sortable: true },
|
||||||
{ title: "SNR", type: "normal", sortable: true },
|
{ title: "SNR", type: "normal", sortable: true },
|
||||||
|
{ title: "Encryption", type: "normal", sortable: false },
|
||||||
{ title: "Connection", type: "normal", sortable: true },
|
{ title: "Connection", type: "normal", sortable: true },
|
||||||
{ title: "Remove", type: "normal", sortable: false },
|
{ title: "Remove", type: "normal", sortable: false },
|
||||||
]}
|
]}
|
||||||
rows={filteredNodes.map((node) => [
|
rows={filteredNodes.map((node) => [
|
||||||
<Hashicon size={24} value={node.num.toString()} />,
|
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||||
<h1>
|
<h1 key="header">
|
||||||
{node.user?.longName ??
|
{node.user?.longName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `Meshtastic ${base16
|
? `Meshtastic ${base16
|
||||||
@@ -50,37 +53,60 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
: `UNK: ${node.num}`)}
|
: `UNK: ${node.num}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
|
||||||
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
|
<Mono key="model">
|
||||||
<Mono>
|
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||||
|
</Mono>,
|
||||||
|
<Mono key="addr">
|
||||||
{base16
|
{base16
|
||||||
.stringify(node.user?.macaddr ?? [])
|
.stringify(node.user?.macaddr ?? [])
|
||||||
.match(/.{1,2}/g)
|
.match(/.{1,2}/g)
|
||||||
?.join(":") ?? "UNK"}
|
?.join(":") ?? "UNK"}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
node.lastHeard === 0 ? (
|
<Fragment key="lastHeard">
|
||||||
|
{node.lastHeard === 0 ? (
|
||||||
<p>Never</p>
|
<p>Never</p>
|
||||||
) : (
|
) : (
|
||||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||||
),
|
)}
|
||||||
<Mono>
|
</Fragment>,
|
||||||
|
<Mono key="snr">
|
||||||
{node.snr}db/
|
{node.snr}db/
|
||||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||||
{(node.snr + 10) * 5}raw
|
{(node.snr + 10) * 5}raw
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Mono>
|
<Mono key="pki">
|
||||||
{node.lastHeard != 0 ?
|
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||||
(node.viaMqtt === false && node.hopsAway === 0
|
<LockIcon className="text-green-600" />
|
||||||
? "Direct": node.hopsAway.toString() + " hops away")
|
) : (
|
||||||
: "-"}
|
<LockOpenIcon className="text-yellow-300" />
|
||||||
{node.viaMqtt === true? ", via MQTT": ""}
|
)}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Button variant="destructive" onClick={() => {
|
<Mono key="hops">
|
||||||
|
{node.lastHeard !== 0
|
||||||
|
? node.viaMqtt === false && node.hopsAway === 0
|
||||||
|
? "Direct"
|
||||||
|
: `${node.hopsAway.toString()} ${
|
||||||
|
node.hopsAway > 1 ? "hops" : "hop"
|
||||||
|
} away`
|
||||||
|
: "-"}
|
||||||
|
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||||
|
</Mono>,
|
||||||
|
<Button
|
||||||
|
key="remove"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
setNodeNumToBeRemoved(node.num);
|
setNodeNumToBeRemoved(node.num);
|
||||||
setDialogOpen("nodeRemoval", true)
|
setDialogOpen("nodeRemoval", true);
|
||||||
}}><TrashIcon />Remove</Button>
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Remove
|
||||||
|
</Button>,
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -59,4 +60,7 @@ export class LoRaValidation
|
|||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
ignoreMqtt: boolean;
|
ignoreMqtt: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
configOkToMqtt: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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
@@ -1,5 +1 @@
|
|||||||
{
|
{ "github": { "silent": true } }
|
||||||
"github": {
|
|
||||||
"silent": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
import { execSync } from "child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { resolve } from "path";
|
import { resolve } from "node:path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
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";
|
||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
let hash = "";
|
let hash = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user