mirror of
https://github.com/meshtastic/web.git
synced 2025-12-30 02:57:54 -05:00
Compare commits
160 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 |
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -21,42 +24,3 @@ jobs:
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
|
||||
- name: Package Output
|
||||
run: pnpm package
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
automatic_release_tag: "latest"
|
||||
prerelease: false
|
||||
files: |
|
||||
./dist/build.tar
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Buildah Build
|
||||
id: build-container
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: latest ${{ github.sha }}
|
||||
oci: true
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
- name: Push To Registry
|
||||
id: push-to-registry
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-container.outputs.image }}
|
||||
tags: ${{ steps.build-container.outputs.tags }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Print image url
|
||||
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
||||
|
||||
9
.github/workflows/pr.yml
vendored
9
.github/workflows/pr.yml
vendored
@@ -19,3 +19,12 @@ jobs:
|
||||
|
||||
- name: Build Package
|
||||
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
|
||||
|
||||
61
.github/workflows/release.yml
vendored
Normal file
61
.github/workflows/release.yml
vendored
Normal file
@@ -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 }}"
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["vercel.json"]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
@@ -20,7 +21,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"all": true
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
package.json
74
package.json
@@ -6,8 +6,9 @@
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc && pnpm check && vite build ",
|
||||
"check": "biome check .",
|
||||
"check:fix": "pnpm check --write",
|
||||
"preview": "vite preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)"
|
||||
},
|
||||
@@ -20,64 +21,67 @@
|
||||
},
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.8.0",
|
||||
"@bufbuild/protobuf": "^1.10.0",
|
||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||
"@meshtastic/js": "2.3.4-0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-menubar": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@meshtastic/js": "2.3.7-5",
|
||||
"@noble/curves": "^1.5.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.1",
|
||||
"@turf/turf": "^6.5.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"immer": "^10.0.4",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
|
||||
"mapbox-gl": "^3.6.0",
|
||||
"maplibre-gl": "4.1.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-map-gl": "7.1.7",
|
||||
"react-qrcode-logo": "^2.9.0",
|
||||
"react-qrcode-logo": "^2.10.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timeago-react": "^3.0.6",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.6.3",
|
||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2",
|
||||
"@biomejs/biome": "^1.8.2",
|
||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1",
|
||||
"@types/chrome": "^0.0.263",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.23",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/w3c-web-serial": "^1.0.6",
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"gzipper": "^7.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tar": "^6.2.1",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.6",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
8197
pnpm-lock.yaml
generated
8197
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.js";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
|
||||
import { Toaster } from "@components/Toaster.js";
|
||||
import Footer from "@components/UI/Footer.js";
|
||||
import { ThemeController } from "@components/generic/ThemeController.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
|
||||
<PageRouter />
|
||||
</div>
|
||||
) : (
|
||||
<Dashboard />
|
||||
<>
|
||||
<Dashboard />
|
||||
<div className="flex flex-grow" />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
LayersIcon,
|
||||
LayoutIcon,
|
||||
LinkIcon,
|
||||
LucideIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
MoonIcon,
|
||||
@@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset",
|
||||
label: "Factory Reset Device",
|
||||
icon: FactoryIcon,
|
||||
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);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, []);
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
LanguagesIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SunIcon,
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
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 { ImportDialog } from "@components/Dialog/ImportDialog.js";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.js";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.js";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
|
||||
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
|
||||
export const DialogManager = (): JSX.Element => {
|
||||
|
||||
@@ -26,19 +26,34 @@ export const ImportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportDialogProps): JSX.Element => {
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||
|
||||
const { connection } = useDevice();
|
||||
|
||||
useEffect(() => {
|
||||
const base64String = qrCodeUrl.split("e/#")[1];
|
||||
const paddedString = base64String
|
||||
?.padEnd(base64String.length + ((4 - (base64String.length % 4)) % 4), "=")
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
// the channel information is contained in the URL's fragment, which will be present after a
|
||||
// non-URL encoded `#`.
|
||||
try {
|
||||
const channelsUrl = new URL(importDialogInput);
|
||||
if (
|
||||
(channelsUrl.hostname !== "meshtastic.org" &&
|
||||
channelsUrl.pathname !== "/e/") ||
|
||||
!channelsUrl.hash
|
||||
) {
|
||||
throw "Invalid Meshtastic URL";
|
||||
}
|
||||
|
||||
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(
|
||||
encodedChannelConfig.length +
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
"=",
|
||||
)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
setChannelSet(
|
||||
Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)),
|
||||
);
|
||||
@@ -47,7 +62,7 @@ export const ImportDialog = ({
|
||||
setValidUrl(false);
|
||||
setChannelSet(undefined);
|
||||
}
|
||||
}, [qrCodeUrl]);
|
||||
}, [importDialogInput]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.map((ch, index) => {
|
||||
@@ -87,10 +102,10 @@ export const ImportDialog = ({
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Channel Set/QR Code URL</Label>
|
||||
<Input
|
||||
value={qrCodeUrl}
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
onChange={(e) => {
|
||||
setQrCodeUrl(e.target.value);
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{validUrl && (
|
||||
|
||||
39
src/components/Dialog/PkiRegenerateDialog.tsx
Normal file
39
src/components/Dialog/PkiRegenerateDialog.tsx
Normal file
@@ -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";
|
||||
import { Input } from "@components/UI/Input.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 { ClipboardIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
|
||||
export interface QRDialogProps {
|
||||
@@ -32,7 +32,7 @@ export const QRDialog = ({
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
|
||||
|
||||
useEffect(() => {
|
||||
const channelsToEncode = allChannels
|
||||
@@ -50,8 +50,10 @@ export const QRDialog = ({
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`);
|
||||
}, [channels, selectedChannels, qrCodeAdd, loraConfig]);
|
||||
setQrCodeUrl(
|
||||
`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
|
||||
);
|
||||
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -97,18 +99,26 @@ export const QRDialog = ({
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={ "border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
|
||||
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"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(true)}
|
||||
>
|
||||
Add Channels
|
||||
>
|
||||
Add Channels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ "border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (!qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
|
||||
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"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(false)}
|
||||
>
|
||||
Replace Channels
|
||||
>
|
||||
Replace Channels
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const RebootDialog = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(parseInt(e.target.value))}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
action={{
|
||||
icon: ClockIcon,
|
||||
onClick() {
|
||||
|
||||
@@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ShutdownDialog = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(parseInt(e.target.value))}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
suffix="Minutes"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
DynamicFormField,
|
||||
FieldProps,
|
||||
type FieldProps,
|
||||
} from "@components/Form/DynamicFormField.js";
|
||||
import { FieldWrapper } from "@components/Form/FormWrapper.js";
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { H4 } from "@components/UI/Typography/H4.js";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.js";
|
||||
import {
|
||||
Control,
|
||||
DefaultValues,
|
||||
FieldValues,
|
||||
Path,
|
||||
SubmitHandler,
|
||||
type Control,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
|
||||
@@ -23,10 +23,12 @@ interface DisabledBy<T> {
|
||||
|
||||
export interface BaseFormBuilderProps<T> {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
disabledBy?: DisabledBy<T>[];
|
||||
label: string;
|
||||
description?: string;
|
||||
properties?: {};
|
||||
validationText?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GenericFormElementProps<T extends FieldValues, Y> {
|
||||
@@ -39,11 +41,12 @@ export interface DynamicFormProps<T extends FieldValues> {
|
||||
onSubmit: SubmitHandler<T>;
|
||||
submitType?: "onChange" | "onSubmit";
|
||||
hasSubmitButton?: boolean;
|
||||
// defaultValues?: DeepPartial<T>;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
fieldGroups: {
|
||||
label: string;
|
||||
description: string;
|
||||
valid?: boolean;
|
||||
validationText?: string;
|
||||
fields: FieldProps<T>[];
|
||||
}[];
|
||||
}
|
||||
@@ -60,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
|
||||
defaultValues: defaultValues,
|
||||
});
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
): boolean => {
|
||||
if (disabled) return true;
|
||||
if (!disabledBy) return false;
|
||||
|
||||
return disabledBy.some((field) => {
|
||||
const value = getValues(field.fieldName);
|
||||
if (value === "always") return true;
|
||||
if (typeof value === "boolean") return field.invert ? value : !value;
|
||||
if (typeof value === "number")
|
||||
return field.invert
|
||||
@@ -94,12 +102,20 @@ export function DynamicForm<T extends FieldValues>({
|
||||
</div>
|
||||
|
||||
{fieldGroup.fields.map((field) => (
|
||||
<FieldWrapper label={field.label} description={field.description}>
|
||||
<FieldWrapper
|
||||
key={field.label}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
valid={
|
||||
field.validationText === undefined ||
|
||||
field.validationText === ""
|
||||
}
|
||||
validationText={field.validationText}
|
||||
>
|
||||
<DynamicFormField
|
||||
key={field.label}
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy)}
|
||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js";
|
||||
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js";
|
||||
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js";
|
||||
import {
|
||||
GenericInput,
|
||||
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";
|
||||
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
| SelectFieldProps<T>
|
||||
| ToggleFieldProps<T>;
|
||||
| ToggleFieldProps<T>
|
||||
| PasswordGeneratorProps<T>;
|
||||
|
||||
export interface DynamicFormFieldProps<T extends FieldValues> {
|
||||
field: FieldProps<T>;
|
||||
@@ -35,6 +49,14 @@ export function DynamicFormField<T extends FieldValues>({
|
||||
return (
|
||||
<SelectInput field={field} control={control} disabled={disabled} />
|
||||
);
|
||||
case "passwordGenerator":
|
||||
return (
|
||||
<PasswordGenerator
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return <div>tmp</div>;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
} from "@components/Form/DynamicForm.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
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> {
|
||||
type: "text" | "number" | "password";
|
||||
inputChange?: ChangeEventHandler;
|
||||
properties?: {
|
||||
value?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
|
||||
46
src/components/Form/FormPasswordGenerator.tsx
Normal file
46
src/components/Form/FormPasswordGenerator.tsx
Normal file
@@ -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,
|
||||
SelectValue,
|
||||
} 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> {
|
||||
type: "select" | "multiSelect";
|
||||
@@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
|
||||
: [];
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(e) => onChange(parseInt(e))}
|
||||
onValueChange={(e) => onChange(Number.parseInt(e))}
|
||||
disabled={disabled}
|
||||
value={value?.toString()}
|
||||
{...remainingProperties}
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.js";
|
||||
import { Switch } from "@components/UI/Switch.js";
|
||||
import { ChangeEvent } from "react";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "toggle";
|
||||
|
||||
@@ -5,12 +5,16 @@ export interface FieldWrapperProps {
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
valid?: boolean;
|
||||
validationText?: string;
|
||||
}
|
||||
|
||||
export const FieldWrapper = ({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
valid,
|
||||
validationText,
|
||||
}: FieldWrapperProps): JSX.Element => (
|
||||
<div className="pt-6 sm:pt-5">
|
||||
<div role="group" aria-labelledby="label-notifications">
|
||||
@@ -19,6 +23,9 @@ export const FieldWrapper = ({
|
||||
<div className="sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
<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="flex items-center">{children}</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 { useToast } from "@core/hooks/useToast.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
@@ -13,15 +15,27 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
const { config, connection, addChannel } = useDevice();
|
||||
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 channel = new Protobuf.Channel.Channel({
|
||||
...data,
|
||||
settings: {
|
||||
...data.settings,
|
||||
psk: toByteArray(data.settings.psk ?? ""),
|
||||
psk: toByteArray(pass),
|
||||
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(() => {
|
||||
@@ -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 (
|
||||
<DynamicForm<ChannelValidation>
|
||||
onSubmit={onSubmit}
|
||||
@@ -42,10 +88,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
...{
|
||||
settings: {
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32,
|
||||
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision
|
||||
psk: pass,
|
||||
positionEnabled:
|
||||
channel?.settings?.moduleSettings?.positionPrecision !==
|
||||
undefined &&
|
||||
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||
preciseLocation:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -65,12 +118,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
type: "passwordGenerator",
|
||||
name: "settings.psk",
|
||||
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: {
|
||||
// act
|
||||
value: pass,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -111,9 +169,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
description:
|
||||
"If not sharing precise location, position shared on channel 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 }
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
|
||||
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",
|
||||
name: "buttonGpio",
|
||||
@@ -86,12 +73,6 @@ export const Device = (): JSX.Element => {
|
||||
label: "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",
|
||||
name: "disableTripleClick",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
|
||||
label: "Hop Limit",
|
||||
description: "Maximum number of hops",
|
||||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
237
src/components/PageComponents/Config/Security.tsx
Normal file
237
src/components/PageComponents/Config/Security.tsx
Normal file
@@ -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 { Mono } from "@components/generic/Mono.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
|
||||
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Input } from "@components/UI/Input.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 { randId } from "@core/utils/randId.js";
|
||||
import { HttpConnection } from "@meshtastic/js";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
@@ -20,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
}>({
|
||||
defaultValues: {
|
||||
ip: ["client.meshtastic.org", "localhost"].includes(
|
||||
window.location.hostname
|
||||
window.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: window.location.hostname,
|
||||
@@ -38,7 +38,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setConnectionInProgress(true);
|
||||
|
||||
|
||||
const id = randId();
|
||||
const device = addDevice(id);
|
||||
const connection = new HttpConnection(id);
|
||||
@@ -75,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
<Switch
|
||||
// label="Use TLS"
|
||||
// description="Description"
|
||||
disabled={location.protocol === "https:" || connectionInProgress}
|
||||
disabled={
|
||||
location.protocol === "https:" || connectionInProgress
|
||||
}
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -84,7 +86,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={connectionInProgress}>
|
||||
<span>{connectionInProgress ? 'Connecting...' : 'Connect' }</span>
|
||||
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
|
||||
</Button>
|
||||
</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 { Mono } from "@components/generic/Mono.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
@@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 p-4">
|
||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
disabled={port.readable !== null}
|
||||
onClick={async () => {
|
||||
await onConnect(port);
|
||||
}}
|
||||
>
|
||||
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${
|
||||
port.getInfo().usbProductId ?? "UNK"
|
||||
}`}
|
||||
</Button>
|
||||
))}
|
||||
{serialPorts.map((port, index) => {
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
return (
|
||||
<Button
|
||||
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
|
||||
disabled={port.readable !== null}
|
||||
onClick={async () => {
|
||||
await onConnect(port);
|
||||
}}
|
||||
>
|
||||
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
|
||||
usbProductId ?? "UNK"
|
||||
}`}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{serialPorts.length === 0 && (
|
||||
<Mono className="m-auto select-none">No devices paired yet.</Mono>
|
||||
)}
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.js";
|
||||
import { 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 { 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";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageWithState[];
|
||||
channel: Types.ChannelNumber;
|
||||
to: Types.Destination;
|
||||
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
|
||||
}
|
||||
|
||||
export const ChannelChat = ({
|
||||
messages,
|
||||
channel,
|
||||
to,
|
||||
traceroutes,
|
||||
}: ChannelChatProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex flex-grow flex-col">
|
||||
{messages ? (
|
||||
messages.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0 ? false : messages[index - 1].from === message.from
|
||||
}
|
||||
sender={nodes.get(message.from)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto">
|
||||
<InboxIcon className="m-auto" />
|
||||
<Subtle>No Messages</Subtle>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-grow">
|
||||
<div className="flex flex-grow flex-col">
|
||||
{messages ? (
|
||||
messages.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0
|
||||
? false
|
||||
: messages[index - 1].from === message.from
|
||||
}
|
||||
sender={nodes.get(message.from)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto">
|
||||
<InboxIcon className="m-auto" />
|
||||
<Subtle>No Messages</Subtle>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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="p-3">
|
||||
<div className="pl-3 pr-3 pt-3 pb-1">
|
||||
<MessageInput to={to} channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const MessageInput = ({
|
||||
<span className="w-full">
|
||||
<Input
|
||||
autoFocus={true}
|
||||
minLength={2}
|
||||
minLength={1}
|
||||
placeholder="Enter Message"
|
||||
value={messageDraft}
|
||||
onChange={(e) => setMessageDraft(e.target.value)}
|
||||
|
||||
32
src/components/PageComponents/Messages/TraceRoute.tsx
Normal file
32
src/components/PageComponents/Messages/TraceRoute.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore.js";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
|
||||
export interface TraceRouteProps {
|
||||
from?: Protobuf.Mesh.NodeInfo;
|
||||
to?: Protobuf.Mesh.NodeInfo;
|
||||
route: Array<number>;
|
||||
}
|
||||
|
||||
export const TraceRoute = ({
|
||||
from,
|
||||
to,
|
||||
route,
|
||||
}: TraceRouteProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return route.length === 0 ? (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
{to?.user?.longName}↔{from?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
{to?.user?.longName}↔
|
||||
{route.map((hop) => `${nodes.get(hop)?.user?.longName ?? "Unknown"}↔`)}
|
||||
{from?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
|
||||
label: "Minimum Broadcast Seconds",
|
||||
description:
|
||||
"The interval in seconds of how often we can send a message to the mesh when a state change is detected",
|
||||
properties: {
|
||||
suffix: "Seconds",
|
||||
},
|
||||
properties: {
|
||||
suffix: "Seconds",
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
|
||||
@@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
|
||||
export const MQTT = (): JSX.Element => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
|
||||
const onSubmit = (data: MqttValidation) => {
|
||||
setWorkingModuleConfig(
|
||||
new Protobuf.ModuleConfig.ModuleConfig({
|
||||
payloadVariant: {
|
||||
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",
|
||||
name: "encryptionEnabled",
|
||||
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: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
@@ -151,10 +158,39 @@ export const MQTT = (): JSX.Element => {
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
type: "select",
|
||||
name: "mapReportSettings.positionPrecision",
|
||||
label: "Position Precision",
|
||||
description: "Precision of the position",
|
||||
label: "Approximate Location",
|
||||
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: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
|
||||
@@ -36,7 +36,7 @@ export const NeighborInfo = (): JSX.Element => {
|
||||
type: "number",
|
||||
name: "updateInterval",
|
||||
label: "Update Interval",
|
||||
description:
|
||||
description:
|
||||
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
|
||||
properties: {
|
||||
suffix: "Seconds",
|
||||
|
||||
@@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
|
||||
type: "number",
|
||||
name: "paxcounterUpdateInterval",
|
||||
label: "Update Interval (seconds)",
|
||||
description: "How long to wait between sending paxcounter packets",
|
||||
description:
|
||||
"How long to wait between sending paxcounter packets",
|
||||
properties: {
|
||||
suffix: "Seconds",
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
|
||||
description: "How often to send Power data over the mesh",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
type: "toggle",
|
||||
name: "powerScreenEnabled",
|
||||
label: "Power Screen Enabled",
|
||||
description: "Enable the Power Telemetry Screen",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
label: string;
|
||||
@@ -7,6 +8,7 @@ export interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
actions?: {
|
||||
icon: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}
|
||||
@@ -18,40 +20,43 @@ export const PageLayout = ({
|
||||
children,
|
||||
}: PageLayoutProps): JSX.Element => {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
||||
<button
|
||||
type="button"
|
||||
className="pl-4 transition-all hover:text-accent md:hidden"
|
||||
>
|
||||
<AlignLeftIcon />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon />
|
||||
</button>
|
||||
))}
|
||||
<>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="pl-4 transition-all hover:text-accent md:hidden"
|
||||
>
|
||||
<AlignLeftIcon />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className={action.iconClasses} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-y-auto",
|
||||
!noPadding && "pl-3 pr-3 ",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-y-auto",
|
||||
!noPadding && "p-3",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,15 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import type { Page } from "@core/stores/deviceStore.js";
|
||||
import {
|
||||
BatteryMediumIcon,
|
||||
CpuIcon,
|
||||
EditIcon,
|
||||
LayersIcon,
|
||||
LucideIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
ZapIcon,
|
||||
BatteryMediumIcon
|
||||
} from "lucide-react";
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -20,8 +21,9 @@ export interface SidebarProps {
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
const { hardware, nodes } = useDevice();
|
||||
const { hardware, nodes, metadata } = useDevice();
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
const myMetadata = metadata.get(0);
|
||||
const { activePage, setActivePage, setDialogOpen } = useDevice();
|
||||
|
||||
interface NavLink {
|
||||
@@ -77,12 +79,18 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
</div>
|
||||
<div className="px-8 pb-6">
|
||||
<div className="flex items-center">
|
||||
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/>
|
||||
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
|
||||
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
|
||||
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ZapIcon size={24} viewBox={'0 0 36 24'}/>
|
||||
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle>
|
||||
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -17,8 +17,14 @@ export function Toaster() {
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>}
|
||||
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>}
|
||||
{title && (
|
||||
<ToastTitle className="dark:text-white">{title}</ToastTitle>
|
||||
)}
|
||||
{description && (
|
||||
<ToastDescription className="dark:text-white-400">
|
||||
{description}
|
||||
</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<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 { 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",
|
||||
destructive:
|
||||
"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:
|
||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
|
||||
subtle:
|
||||
|
||||
37
src/components/UI/Footer.tsx
Normal file
37
src/components/UI/Footer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
|
||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<footer
|
||||
className={`flex flex- justify-center p-2 ${className}`}
|
||||
style={{
|
||||
backgroundColor: "var(--backgroundPrimary)",
|
||||
color: "var(--textPrimary)",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<a
|
||||
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--link)" }}
|
||||
>
|
||||
Powered by ▲ Vercel
|
||||
</a>{" "}
|
||||
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
|
||||
<a
|
||||
href="https://meshtastic.org/docs/legal"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--link)" }}
|
||||
>
|
||||
Legal Information
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
111
src/components/UI/Generator.tsx
Normal file
111
src/components/UI/Generator.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.js";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
hide?: boolean;
|
||||
devicePSKBitCount?: number;
|
||||
value: string;
|
||||
variant: "default" | "invalid";
|
||||
buttonText?: string;
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
selectChange: (event: string) => void;
|
||||
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
};
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
(
|
||||
{
|
||||
hide = true,
|
||||
devicePSKBitCount,
|
||||
variant,
|
||||
value,
|
||||
buttonText,
|
||||
bits = [
|
||||
{ text: "256 bit", value: "32", key: "bit256" },
|
||||
{ text: "128 bit", value: "16", key: "bit128" },
|
||||
{ text: "8 bit", value: "1", key: "bit8" },
|
||||
],
|
||||
selectChange,
|
||||
inputChange,
|
||||
buttonClick,
|
||||
action,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Invokes onChange event on the input element when the value changes from the parent component
|
||||
React.useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
if (!setValue) return;
|
||||
inputRef.current.value = "";
|
||||
setValue.call(inputRef.current, value);
|
||||
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [value]);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type={hide ? "password" : "text"}
|
||||
id="pskInput"
|
||||
variant={variant}
|
||||
value={value}
|
||||
onChange={inputChange}
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Select
|
||||
value={devicePSKBitCount?.toString()}
|
||||
onValueChange={(e) => selectChange(e)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="!max-w-max">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bits.map(({ text, value, key }) => (
|
||||
<SelectItem key={key} value={value}>
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="success"
|
||||
onClick={buttonClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
Generator.displayName = "Button";
|
||||
|
||||
export { Generator };
|
||||
@@ -1,10 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.js";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
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
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
@@ -14,7 +31,7 @@ export interface InputProps
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, prefix, suffix, action, ...props }, ref) => {
|
||||
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{prefix && (
|
||||
@@ -24,10 +41,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
)}
|
||||
<input
|
||||
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",
|
||||
className,
|
||||
inputVariants({ variant }),
|
||||
)}
|
||||
value={value}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -51,4 +69,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
export { Input, inputVariants };
|
||||
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 * 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 {
|
||||
headings: Heading[];
|
||||
@@ -12,6 +13,49 @@ export interface Heading {
|
||||
}
|
||||
|
||||
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 (
|
||||
<table className="min-w-full">
|
||||
<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"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => heading.sortable && headingSort(heading.title)}
|
||||
onKeyUp={() => heading.sortable && headingSort(heading.title)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
{heading.title}
|
||||
{heading.sortable && (
|
||||
<ChevronUpIcon size={16} className="my-auto" />
|
||||
{sortColumn === heading.title && (
|
||||
<>
|
||||
{sortOrder === "asc" ? (
|
||||
<ChevronUpIcon size={16} />
|
||||
) : (
|
||||
<ChevronDownIcon size={16} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
@@ -37,10 +89,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
|
||||
</tr>
|
||||
</thead>
|
||||
<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}>
|
||||
{row.map((item, index) => (
|
||||
<td
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
|
||||
key={index}
|
||||
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
|
||||
>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -92,9 +92,9 @@ export const reducer = (state: State, action: Action): State => {
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
for (const toast of state.toasts) {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -130,9 +130,9 @@ let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
for (const listener of listeners) {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
@@ -166,18 +166,22 @@ function toast({ ...props }: Toast) {
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = useState<State>(memoryState);
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return function unsubscribe() {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
const getState = () => {
|
||||
return memoryState;
|
||||
};
|
||||
|
||||
function useToast() {
|
||||
const state = useSyncExternalStore(subscribe, getState);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
currentPage: "messages",
|
||||
rasterSources: [],
|
||||
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",
|
||||
connectDialogOpen: false,
|
||||
nodeNumToBeRemoved: 0,
|
||||
@@ -96,7 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
);
|
||||
},
|
||||
setDarkMode: (enabled: boolean) => {
|
||||
localStorage.setItem('theme-dark', enabled.toString());
|
||||
localStorage.setItem("theme-dark", enabled.toString());
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.darkMode = enabled;
|
||||
@@ -105,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
},
|
||||
setNodeNumToBeRemoved: (nodeNum) =>
|
||||
set((state) => ({
|
||||
nodeNumToBeRemoved: nodeNum
|
||||
nodeNumToBeRemoved: nodeNum,
|
||||
})),
|
||||
setAccent(color) {
|
||||
set(
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface Device {
|
||||
direct: Map<number, MessageWithState[]>;
|
||||
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
|
||||
};
|
||||
traceroutes: Map<
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
>;
|
||||
connection?: Types.ConnectionType;
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
@@ -75,6 +79,9 @@ export interface Device {
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addConnection: (connection: Types.ConnectionType) => void;
|
||||
addMessage: (message: MessageWithState) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
) => void;
|
||||
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
setMessageState: (
|
||||
@@ -122,6 +129,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
direct: new Map(),
|
||||
broadcast: new Map(),
|
||||
},
|
||||
traceroutes: new Map(),
|
||||
connection: undefined,
|
||||
activePage: "messages",
|
||||
activeNode: 0,
|
||||
@@ -183,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
device.config.bluetooth = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "security": {
|
||||
device.config.security = config.payloadVariant.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -487,6 +498,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
addMetadata: (from, metadata) => {
|
||||
set(
|
||||
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) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
@@ -506,8 +538,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
device.nodes.delete(nodeNum);
|
||||
})
|
||||
)
|
||||
}),
|
||||
);
|
||||
},
|
||||
setMessageState: (
|
||||
type: "direct" | "broadcast",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Device } from "@core/stores/deviceStore.js";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { Protobuf, type Types } from "@meshtastic/js";
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
@@ -86,6 +86,12 @@ export const subscribeAll = (
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||
device.addTraceRoute({
|
||||
...traceRoutePacket,
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onPendingSettingsChange.subscribe((state) => {
|
||||
device.setPendingSettingsChanges(state);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
||||
15
src/core/utils/x25519.ts
Normal file
15
src/core/utils/x25519.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
|
||||
export function getX25519PrivateKey(): Uint8Array {
|
||||
const key = x25519.utils.randomPrivateKey();
|
||||
|
||||
key[0] &= 248;
|
||||
key[31] &= 127;
|
||||
key[31] |= 64;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return x25519.getPublicKey(privateKey);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
|
||||
import { Network } from "@components/PageComponents/Config/Network.js";
|
||||
import { Position } from "@components/PageComponents/Config/Position.js";
|
||||
import { Power } from "@components/PageComponents/Config/Power.js";
|
||||
import { Security } from "@components/PageComponents/Config/Security.js";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -47,16 +48,17 @@ export const DeviceConfig = (): JSX.Element => {
|
||||
label: "Bluetooth",
|
||||
element: Bluetooth,
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
element: Security,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="Device">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
>
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Audio } from "@components/PageComponents/ModuleConfig/Audio.js";
|
||||
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js";
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.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 { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
|
||||
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
|
||||
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
|
||||
@@ -21,79 +21,81 @@ export const Dashboard = () => {
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<H3>Connected Devices</H3>
|
||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<H3>Connected Devices</H3>
|
||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-gray-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? "UNK"}
|
||||
</p>
|
||||
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
||||
{device.connection?.connType === "ble" && (
|
||||
<>
|
||||
<BluetoothIcon size={16} />
|
||||
BLE
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "serial" && (
|
||||
<>
|
||||
<UsbIcon size={16} />
|
||||
Serial
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "http" && (
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Network
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-gray-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||
<H3>No Devices</H3>
|
||||
<Subtle>Connect at least one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
New Connection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-gray-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? "UNK"}
|
||||
</p>
|
||||
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
||||
{device.connection?.connType === "ble" && (
|
||||
<>
|
||||
<BluetoothIcon size={16} />
|
||||
BLE
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "serial" && (
|
||||
<>
|
||||
<UsbIcon size={16} />
|
||||
Serial
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "http" && (
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Network
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-gray-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||
<H3>No Devices</H3>
|
||||
<Subtle>Connect atleast one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
New Connection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Marker, useMap } from "react-map-gl";
|
||||
import MapGl from "react-map-gl/maplibre";
|
||||
|
||||
@@ -27,7 +27,7 @@ export const MapPage = (): JSX.Element => {
|
||||
|
||||
const allNodes = Array.from(nodes.values());
|
||||
|
||||
const getBBox = () => {
|
||||
const getBBox = useCallback(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const MapPage = (): JSX.Element => {
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
};
|
||||
}, [allNodes, map]);
|
||||
|
||||
useEffect(() => {
|
||||
map?.on("zoom", () => {
|
||||
@@ -128,7 +128,11 @@ export const MapPage = (): JSX.Element => {
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
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}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={{
|
||||
@@ -140,8 +144,8 @@ export const MapPage = (): JSX.Element => {
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={wp.longitudeI / 1e7}
|
||||
latitude={wp.latitudeI / 1e7}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
@@ -159,23 +163,21 @@ export const MapPage = (): JSX.Element => {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
longitude={node.position.longitudeI / 1e7}
|
||||
latitude={node.position.latitudeI / 1e7}
|
||||
style = {{filter: darkMode ? 'invert(1)' : ''}}
|
||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
(node.position?.longitudeI ?? 0) / 1e7,
|
||||
(node.position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
|
||||
onClick={() => {
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
(node.position?.longitudeI ?? 0) / 1e7,
|
||||
(node.position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||
<Hashicon value={node.num.toString()} size={22} />
|
||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||
{node.user?.longName}
|
||||
|
||||
@@ -3,15 +3,17 @@ import { PageLayout } from "@components/PageLayout.js";
|
||||
import { Sidebar } from "@components/Sidebar.js";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
|
||||
import { useToast } from "@core/hooks/useToast.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { getChannelName } from "@pages/Channels.js";
|
||||
import { HashIcon } from "lucide-react";
|
||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const MessagesPage = (): JSX.Element => {
|
||||
const { channels, nodes, hardware, messages } = useDevice();
|
||||
const { channels, nodes, hardware, messages, traceroutes, connection } =
|
||||
useDevice();
|
||||
const [chatType, setChatType] =
|
||||
useState<Types.PacketDestination>("broadcast");
|
||||
const [activeChat, setActiveChat] = useState<number>(
|
||||
@@ -25,6 +27,7 @@ export const MessagesPage = (): JSX.Element => {
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
);
|
||||
const currentChannel = channels.get(activeChat);
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,38 +67,79 @@ export const MessagesPage = (): JSX.Element => {
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<PageLayout
|
||||
label={`Messages: ${
|
||||
chatType === "broadcast" && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
||||
: "Loading..."
|
||||
}`}
|
||||
>
|
||||
{allChannels.map(
|
||||
(channel) =>
|
||||
activeChat === channel.index && (
|
||||
<ChannelChat
|
||||
key={channel.index}
|
||||
to="broadcast"
|
||||
messages={messages.broadcast.get(channel.index)}
|
||||
channel={channel.index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{filteredNodes.map(
|
||||
(node) =>
|
||||
activeChat === node.num && (
|
||||
<ChannelChat
|
||||
key={node.num}
|
||||
to={activeChat}
|
||||
messages={messages.direct.get(node.num)}
|
||||
channel={Types.ChannelNumber.Primary}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</PageLayout>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<PageLayout
|
||||
label={`Messages: ${
|
||||
chatType === "broadcast" && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
||||
: "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(
|
||||
(channel) =>
|
||||
activeChat === channel.index && (
|
||||
<ChannelChat
|
||||
key={channel.index}
|
||||
to="broadcast"
|
||||
messages={messages.broadcast.get(channel.index)}
|
||||
channel={channel.index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{filteredNodes.map(
|
||||
(node) =>
|
||||
activeChat === node.num && (
|
||||
<ChannelChat
|
||||
key={node.num}
|
||||
to={activeChat}
|
||||
messages={messages.direct.get(node.num)}
|
||||
channel={Types.ChannelNumber.Primary}
|
||||
traceroutes={traceroutes.get(node.num)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { Button } from "@components/UI/Button.js";
|
||||
import { Mono } from "@components/generic/Mono.js";
|
||||
import { Table } from "@components/generic/Table/index.js";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -27,59 +28,84 @@ export const NodesPage = (): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div className="w-full overflow-y-auto">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<Hashicon size={24} value={node.num.toString()} />,
|
||||
<h1>
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `UNK: ${node.num}`)}
|
||||
</h1>,
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||
<h1 key="header">
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `UNK: ${node.num}`)}
|
||||
</h1>,
|
||||
|
||||
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
|
||||
<Mono>
|
||||
{base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? "UNK"}
|
||||
</Mono>,
|
||||
node.lastHeard === 0 ? (
|
||||
<p>Never</p>
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
),
|
||||
<Mono>
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono>
|
||||
{node.lastHeard != 0 ?
|
||||
(node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct": node.hopsAway.toString() + " hops away")
|
||||
: "-"}
|
||||
{node.viaMqtt === true? ", via MQTT": ""}
|
||||
</Mono>,
|
||||
<Button variant="destructive" onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true)
|
||||
}}><TrashIcon />Remove</Button>
|
||||
])}
|
||||
/>
|
||||
<Mono key="model">
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>,
|
||||
<Mono key="addr">
|
||||
{base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? "UNK"}
|
||||
</Mono>,
|
||||
<Fragment key="lastHeard">
|
||||
{node.lastHeard === 0 ? (
|
||||
<p>Never</p>
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
)}
|
||||
</Fragment>,
|
||||
<Mono key="snr">
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="pki">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300" />
|
||||
)}
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class BluetoothValidation
|
||||
implements Omit<Protobuf.Config.Config_BluetoothConfig, keyof Message>
|
||||
implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_BluetoothConfig,
|
||||
keyof Message | "deviceLoggingEnabled"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class DeviceValidation
|
||||
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message>
|
||||
@@ -34,4 +34,10 @@ export class DeviceValidation
|
||||
|
||||
@IsBoolean()
|
||||
disableTripleClick: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
ledHeartbeatDisabled: boolean;
|
||||
|
||||
@IsString()
|
||||
tzdef: string;
|
||||
}
|
||||
|
||||
@@ -34,4 +34,7 @@ export class DisplayValidation
|
||||
|
||||
@IsBoolean()
|
||||
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";
|
||||
|
||||
export class LoRaValidation
|
||||
implements Omit<Protobuf.Config.Config_LoRaConfig, keyof Message>
|
||||
implements
|
||||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled">
|
||||
{
|
||||
@IsBoolean()
|
||||
usePreset: boolean;
|
||||
@@ -59,4 +60,7 @@ export class LoRaValidation
|
||||
|
||||
@IsBoolean()
|
||||
ignoreMqtt: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
configOkToMqtt: boolean;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime'];
|
||||
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
|
||||
|
||||
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()
|
||||
positionBroadcastSecs: number;
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
|
||||
|
||||
export class PowerValidation
|
||||
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message>
|
||||
implements
|
||||
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
|
||||
{
|
||||
@IsBoolean()
|
||||
isPowerSaving: boolean;
|
||||
|
||||
35
src/validation/config/security.ts
Normal file
35
src/validation/config/security.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsString } from "class-validator";
|
||||
|
||||
export class SecurityValidation
|
||||
implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_SecurityConfig,
|
||||
keyof Message | "adminKey" | "privateKey" | "publicKey"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
adminChannelEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
adminKey: string;
|
||||
|
||||
@IsBoolean()
|
||||
bluetoothLoggingEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
debugLogApiEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isManaged: boolean;
|
||||
|
||||
@IsString()
|
||||
privateKey: string;
|
||||
|
||||
@IsString()
|
||||
publicKey: string;
|
||||
|
||||
@IsBoolean()
|
||||
serialEnabled: boolean;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
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
|
||||
implements
|
||||
@@ -47,8 +53,10 @@ export class MqttValidationMapReportSettings
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message>
|
||||
{
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
publishIntervalSecs: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
positionPrecision: number;
|
||||
}
|
||||
|
||||
@@ -11,4 +11,10 @@ export class PaxcounterValidation
|
||||
|
||||
@IsInt()
|
||||
paxcounterUpdateInterval: number;
|
||||
|
||||
@IsInt()
|
||||
bleThreshold: number;
|
||||
|
||||
@IsInt()
|
||||
wifiThreshold: number;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { IsBoolean, IsInt } from "class-validator";
|
||||
|
||||
export class StoreForwardValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, keyof Message>
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
|
||||
keyof Message | "isServer"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
{
|
||||
"github": {
|
||||
"silent": true
|
||||
}
|
||||
}
|
||||
{ "github": { "silent": true } }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { execSync } from "child_process";
|
||||
import { resolve } from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
import { defineConfig } from "vite";
|
||||
import EnvironmentPlugin from "vite-plugin-environment";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
let hash = "";
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user