Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d2c393ce | ||
|
|
cecdf9758b | ||
|
|
02cb4f2584 | ||
|
|
8cfcd7b1af | ||
|
|
c0cb059f52 | ||
|
|
a2a9b37238 | ||
|
|
57d0d27bbb | ||
|
|
0e92dd9bea | ||
|
|
c16ebf3917 | ||
|
|
3d3a08a23f | ||
|
|
4d1227a942 | ||
|
|
a8ee273b24 | ||
|
|
3ee7a57480 | ||
|
|
2f2c777c56 | ||
|
|
2f36118e9d | ||
|
|
a6d161581f | ||
|
|
d05ea5a2cc | ||
|
|
471db94242 | ||
|
|
2654e4fbc9 | ||
|
|
f2aa5bfbee | ||
|
|
3b018b0c70 | ||
|
|
921db10d91 | ||
|
|
bf4f593e3a | ||
|
|
1e061a1e19 | ||
|
|
9b9f537e2c | ||
|
|
985cce0b0d | ||
|
|
3fe38eb506 | ||
|
|
51081d3052 | ||
|
|
c08f6d16bb | ||
|
|
62ad4c49f8 | ||
|
|
3b0a1e6108 | ||
|
|
c2f2205626 | ||
|
|
87c729d694 | ||
|
|
8e4f60edf3 | ||
|
|
8811eee9f5 | ||
|
|
2af93f1acd | ||
|
|
78a35544c7 | ||
|
|
3ad2d650b0 | ||
|
|
2e12b27566 | ||
|
|
989fad7e17 | ||
|
|
f7a2e5f76b | ||
|
|
2b81fc47e2 | ||
|
|
b522113cd7 | ||
|
|
fce642c24e | ||
|
|
bf425a8ec7 | ||
|
|
a7d0d36086 | ||
|
|
5e72510bef | ||
|
|
88efdc4758 | ||
|
|
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 | ||
|
|
22d900a831 | ||
|
|
c6cc5e5e6f | ||
|
|
f0d8db1c87 | ||
|
|
8c37be4af3 | ||
|
|
076dae80b7 | ||
|
|
7d4001ea9d | ||
|
|
8ed3ce8203 | ||
|
|
ebd5a3d3a6 | ||
|
|
1cdf18747d | ||
|
|
6c1f140ad1 | ||
|
|
4c4be2e18f |
@@ -1,6 +1,9 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on: push
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -21,42 +24,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Package
|
- name: Build Package
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Package Output
|
|
||||||
run: pnpm package
|
|
||||||
|
|
||||||
- name: Upload Artifact
|
|
||||||
uses: "marvinpinto/action-automatic-releases@latest"
|
|
||||||
with:
|
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
automatic_release_tag: "latest"
|
|
||||||
prerelease: false
|
|
||||||
files: |
|
|
||||||
./dist/build.tar
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Buildah Build
|
|
||||||
id: build-container
|
|
||||||
uses: redhat-actions/buildah-build@v2
|
|
||||||
with:
|
|
||||||
containerfiles: |
|
|
||||||
./Containerfile
|
|
||||||
image: ${{github.event.repository.full_name}}
|
|
||||||
tags: latest ${{ github.sha }}
|
|
||||||
oci: true
|
|
||||||
platforms: linux/amd64, linux/arm64
|
|
||||||
|
|
||||||
- name: Push To Registry
|
|
||||||
id: push-to-registry
|
|
||||||
uses: redhat-actions/push-to-registry@v2
|
|
||||||
with:
|
|
||||||
image: ${{ steps.build-container.outputs.image }}
|
|
||||||
tags: ${{ steps.build-container.outputs.tags }}
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Print image url
|
|
||||||
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Package
|
- name: Build Package
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Compress build
|
||||||
|
run: pnpm package
|
||||||
|
|
||||||
|
- name: Archive compressed build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: dist/build.tar
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: 'Release'
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [released]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-package:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build Package
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Package Output
|
||||||
|
run: pnpm package
|
||||||
|
|
||||||
|
- name: Archive compressed build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: dist/build.tar
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Buildah Build
|
||||||
|
id: build-container
|
||||||
|
uses: redhat-actions/buildah-build@v2
|
||||||
|
with:
|
||||||
|
containerfiles: |
|
||||||
|
./Containerfile
|
||||||
|
image: ${{github.event.repository.full_name}}
|
||||||
|
tags: latest ${{ github.sha }}
|
||||||
|
oci: true
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
|
|
||||||
|
- name: Push To Registry
|
||||||
|
id: push-to-registry
|
||||||
|
uses: redhat-actions/push-to-registry@v2
|
||||||
|
with:
|
||||||
|
image: ${{ steps.build-container.outputs.image }}
|
||||||
|
tags: ${{ steps.build-container.outputs.tags }}
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Print image url
|
||||||
|
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
||||||
+5
-3
@@ -23,7 +23,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0",
|
"@bufbuild/protobuf": "^1.10.0",
|
||||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||||
"@meshtastic/js": "2.3.7-1",
|
"@meshtastic/js": "2.3.7-5",
|
||||||
|
"@noble/curves": "^1.5.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.0",
|
"@radix-ui/react-checkbox": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"lucide-react": "^0.363.0",
|
"lucide-react": "^0.363.0",
|
||||||
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
|
"mapbox-gl": "^3.6.0",
|
||||||
"maplibre-gl": "4.1.2",
|
"maplibre-gl": "4.1.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -59,11 +60,12 @@
|
|||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"timeago-react": "^3.0.6",
|
"timeago-react": "^3.0.6",
|
||||||
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"zustand": "4.5.2"
|
"zustand": "4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.8.2",
|
"@biomejs/biome": "^1.8.2",
|
||||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240820152623-fac6975bbc78.1",
|
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1",
|
||||||
"@types/chrome": "^0.0.263",
|
"@types/chrome": "^0.0.263",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|||||||
Generated
+788
-20
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
|
|||||||
import { DialogManager } from "@components/Dialog/DialogManager.js";
|
import { DialogManager } from "@components/Dialog/DialogManager.js";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
|
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
|
||||||
import { Toaster } from "@components/Toaster.js";
|
import { Toaster } from "@components/Toaster.js";
|
||||||
|
import Footer from "@components/UI/Footer.js";
|
||||||
import { ThemeController } from "@components/generic/ThemeController.js";
|
import { ThemeController } from "@components/generic/ThemeController.js";
|
||||||
import { useAppStore } from "@core/stores/appStore.js";
|
import { useAppStore } from "@core/stores/appStore.js";
|
||||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||||
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
|
|||||||
<PageRouter />
|
<PageRouter />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Dashboard />
|
<>
|
||||||
|
<Dashboard />
|
||||||
|
<div className="flex flex-grow" />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,11 +4,14 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.js";
|
} from "@components/Form/DynamicForm.js";
|
||||||
import { Input } from "@components/UI/Input.js";
|
import { Input } from "@components/UI/Input.js";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ChangeEventHandler } from "react";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "text" | "number" | "password";
|
type: "text" | "number" | "password";
|
||||||
|
inputChange?: ChangeEventHandler;
|
||||||
properties?: {
|
properties?: {
|
||||||
|
value?: string;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
type={field.type}
|
type={field.type}
|
||||||
step={field.properties?.step}
|
step={field.properties?.step}
|
||||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
value={field.type === "number" ? Number.parseFloat(value) : value}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
if (field.inputChange) field.inputChange(e);
|
||||||
onChange(
|
onChange(
|
||||||
field.type === "number"
|
field.type === "number"
|
||||||
? Number.parseFloat(e.target.value)
|
? Number.parseFloat(e.target.value)
|
||||||
: e.target.value,
|
: e.target.value,
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
{...field.properties}
|
{...field.properties}
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Controller, type FieldValues } from "react-hook-form";
|
|||||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "passwordGenerator";
|
type: "passwordGenerator";
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
bits?: { text: string; value: string; key: string }[];
|
||||||
devicePSKBitCount: number;
|
devicePSKBitCount: number;
|
||||||
inputChange: ChangeEventHandler;
|
inputChange: ChangeEventHandler;
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
@@ -28,6 +29,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
<Generator
|
<Generator
|
||||||
hide={field.hide}
|
hide={field.hide}
|
||||||
devicePSKBitCount={field.devicePSKBitCount}
|
devicePSKBitCount={field.devicePSKBitCount}
|
||||||
|
bits={field.bits}
|
||||||
inputChange={field.inputChange}
|
inputChange={field.inputChange}
|
||||||
selectChange={field.selectChange}
|
selectChange={field.selectChange}
|
||||||
buttonClick={field.buttonClick}
|
buttonClick={field.buttonClick}
|
||||||
|
|||||||
@@ -111,10 +111,14 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
type: "select",
|
type: "select",
|
||||||
name: "role",
|
name: "role",
|
||||||
label: "Role",
|
label: "Role",
|
||||||
|
disabled: channel.index === 0,
|
||||||
description:
|
description:
|
||||||
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
|
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Channel.Channel_Role,
|
enumValue:
|
||||||
|
channel.index === 0
|
||||||
|
? { PRIMARY: 1 }
|
||||||
|
: { DISABLED: 0, SECONDARY: 2 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,22 @@ export const Device = (): JSX.Element => {
|
|||||||
label: "Role",
|
label: "Role",
|
||||||
description: "What role the device performs on the mesh",
|
description: "What role the device performs on the mesh",
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
enumValue: {
|
||||||
|
Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||||
|
"Client Mute":
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
|
||||||
|
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
||||||
|
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
|
||||||
|
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
|
||||||
|
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
||||||
|
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
|
||||||
|
"Client Hidden":
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
|
||||||
|
"Lost and Found":
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
||||||
|
"TAK Tracker":
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
||||||
|
},
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ export const LoRa = (): JSX.Element => {
|
|||||||
label: "Ignore MQTT",
|
label: "Ignore MQTT",
|
||||||
description: "Don't forward MQTT messages over the mesh",
|
description: "Don't forward MQTT messages over the mesh",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "configOkToMqtt",
|
||||||
|
label: "OK to MQTT",
|
||||||
|
description:
|
||||||
|
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { NetworkValidation } from "@app/validation/config/network.js";
|
import type { NetworkValidation } from "@app/validation/config/network.js";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.js";
|
import { DynamicForm } from "@components/Form/DynamicForm.js";
|
||||||
import { useDevice } from "@core/stores/deviceStore.js";
|
import { useDevice } from "@core/stores/deviceStore.js";
|
||||||
|
import {
|
||||||
|
convertIntToIpAddress,
|
||||||
|
convertIpAddressToInt,
|
||||||
|
} from "@core/utils/ip.js";
|
||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
|
|
||||||
export const Network = (): JSX.Element => {
|
export const Network = (): JSX.Element => {
|
||||||
@@ -13,9 +17,12 @@ export const Network = (): JSX.Element => {
|
|||||||
case: "network",
|
case: "network",
|
||||||
value: {
|
value: {
|
||||||
...data,
|
...data,
|
||||||
ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config(
|
ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config({
|
||||||
data.ipv4Config,
|
ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
|
||||||
),
|
gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
|
||||||
|
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
|
||||||
|
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -25,7 +32,19 @@ export const Network = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<NetworkValidation>
|
<DynamicForm<NetworkValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
defaultValues={config.network}
|
defaultValues={{
|
||||||
|
...config.network,
|
||||||
|
ipv4Config: {
|
||||||
|
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
|
||||||
|
gateway: convertIntToIpAddress(
|
||||||
|
config.network?.ipv4Config?.gateway ?? 0,
|
||||||
|
),
|
||||||
|
subnet: convertIntToIpAddress(
|
||||||
|
config.network?.ipv4Config?.subnet ?? 0,
|
||||||
|
),
|
||||||
|
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||||
|
},
|
||||||
|
}}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "WiFi Config",
|
label: "WiFi Config",
|
||||||
|
|||||||
@@ -77,12 +77,6 @@ export const Position = (): JSX.Element => {
|
|||||||
label: "Enable Pin",
|
label: "Enable Pin",
|
||||||
description: "GPS module enable pin override",
|
description: "GPS module enable pin override",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "number",
|
|
||||||
name: "channelPrecision",
|
|
||||||
label: "Channel Precision",
|
|
||||||
description: "GPS channel precision",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
|
||||||
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
|
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 type { SecurityValidation } from "@app/validation/config/security.js";
|
||||||
import { useDevice } from "@core/stores/deviceStore.js";
|
import { useDevice } from "@core/stores/deviceStore.js";
|
||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ export const Security = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
|
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
|
||||||
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
|
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
|
||||||
config.security?.privateKey.length ?? 16,
|
config.security?.privateKey.length ?? 32,
|
||||||
);
|
);
|
||||||
const [privateKeyValidationText, setPrivateKeyValidationText] =
|
const [privateKeyValidationText, setPrivateKeyValidationText] =
|
||||||
useState<string>();
|
useState<string>();
|
||||||
@@ -23,14 +27,11 @@ export const Security = (): JSX.Element => {
|
|||||||
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
||||||
);
|
);
|
||||||
const [adminKey, setAdminKey] = useState<string>(
|
const [adminKey, setAdminKey] = useState<string>(
|
||||||
fromByteArray(config.security?.adminKey ?? new Uint8Array(0)),
|
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
|
||||||
);
|
|
||||||
const [adminKeyVisible, setAdminKeyVisible] = useState<boolean>(false);
|
|
||||||
const [adminKeyBitCount, setAdminKeyBitCount] = useState<number>(
|
|
||||||
config.security?.adminKey.length ?? 16,
|
|
||||||
);
|
);
|
||||||
const [adminKeyValidationText, setAdminKeyValidationText] =
|
const [adminKeyValidationText, setAdminKeyValidationText] =
|
||||||
useState<string>();
|
useState<string>();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const onSubmit = (data: SecurityValidation) => {
|
const onSubmit = (data: SecurityValidation) => {
|
||||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||||
@@ -41,7 +42,7 @@ export const Security = (): JSX.Element => {
|
|||||||
case: "security",
|
case: "security",
|
||||||
value: {
|
value: {
|
||||||
...data,
|
...data,
|
||||||
adminKey: toByteArray(adminKey),
|
adminKey: [toByteArray(adminKey)],
|
||||||
privateKey: toByteArray(privateKey),
|
privateKey: toByteArray(privateKey),
|
||||||
publicKey: toByteArray(publicKey),
|
publicKey: toByteArray(publicKey),
|
||||||
},
|
},
|
||||||
@@ -50,191 +51,187 @@ export const Security = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clickEvent = (
|
const validateKey = (
|
||||||
setKey: (value: React.SetStateAction<string>) => void,
|
|
||||||
bitCount: number,
|
|
||||||
setValidationText: (
|
|
||||||
value: React.SetStateAction<string | undefined>,
|
|
||||||
) => void,
|
|
||||||
) => {
|
|
||||||
setKey(
|
|
||||||
btoa(
|
|
||||||
cryptoRandomString({
|
|
||||||
length: bitCount ?? 0,
|
|
||||||
type: "alphanumeric",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setValidationText(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePass = (
|
|
||||||
input: string,
|
input: string,
|
||||||
count: number,
|
count: number,
|
||||||
setValidationText: (
|
setValidationText: (
|
||||||
value: React.SetStateAction<string | undefined>,
|
value: React.SetStateAction<string | undefined>,
|
||||||
) => void,
|
) => void,
|
||||||
) => {
|
) => {
|
||||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
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.`);
|
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
||||||
} else {
|
|
||||||
setValidationText(undefined);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = (
|
const privateKeyInputChangeEvent = (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
const psk = e.currentTarget?.value;
|
const privateKeyB64String = e.target.value;
|
||||||
setPrivateKey(psk);
|
setPrivateKey(privateKeyB64String);
|
||||||
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText);
|
validateKey(
|
||||||
|
privateKeyB64String,
|
||||||
|
privateKeyBitCount,
|
||||||
|
setPrivateKeyValidationText,
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
||||||
|
setPublicKey(fromByteArray(publicKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const psk = e.currentTarget?.value;
|
const psk = e.currentTarget?.value;
|
||||||
setAdminKey(psk);
|
setAdminKey(psk);
|
||||||
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText);
|
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeySelectChangeEvent = (e: string) => {
|
const privateKeySelectChangeEvent = (e: string) => {
|
||||||
const count = Number.parseInt(e);
|
const count = Number.parseInt(e);
|
||||||
setPrivateKeyBitCount(count);
|
setPrivateKeyBitCount(count);
|
||||||
validatePass(privateKey, count, setPrivateKeyValidationText);
|
validateKey(privateKey, count, setPrivateKeyValidationText);
|
||||||
};
|
|
||||||
|
|
||||||
const adminKeySelectChangeEvent = (e: string) => {
|
|
||||||
const count = Number.parseInt(e);
|
|
||||||
setAdminKeyBitCount(count);
|
|
||||||
validatePass(privateKey, count, setAdminKeyValidationText);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<SecurityValidation>
|
<>
|
||||||
onSubmit={onSubmit}
|
<DynamicForm<SecurityValidation>
|
||||||
defaultValues={{
|
onSubmit={onSubmit}
|
||||||
...config.security,
|
submitType="onChange"
|
||||||
...{
|
defaultValues={{
|
||||||
adminKey: adminKey,
|
...config.security,
|
||||||
privateKey: privateKey,
|
...{
|
||||||
publicKey: publicKey,
|
adminKey: adminKey,
|
||||||
},
|
privateKey: privateKey,
|
||||||
}}
|
publicKey: publicKey,
|
||||||
fieldGroups={[
|
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
||||||
{
|
isManaged: config.security?.isManaged ?? false,
|
||||||
label: "Security Settings",
|
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
||||||
description: "Settings for the Security configuration",
|
serialEnabled: config.security?.serialEnabled ?? false,
|
||||||
fields: [
|
},
|
||||||
{
|
}}
|
||||||
type: "passwordGenerator",
|
fieldGroups={[
|
||||||
name: "privateKey",
|
{
|
||||||
label: "Private Key",
|
label: "Security Settings",
|
||||||
description: "Used to create a shared key with a remote device",
|
description: "Settings for the Security configuration",
|
||||||
validationText: privateKeyValidationText,
|
fields: [
|
||||||
devicePSKBitCount: privateKeyBitCount,
|
{
|
||||||
inputChange: privateKeyInputChangeEvent,
|
type: "passwordGenerator",
|
||||||
selectChange: privateKeySelectChangeEvent,
|
name: "privateKey",
|
||||||
hide: !privateKeyVisible,
|
label: "Private Key",
|
||||||
buttonClick: () =>
|
description: "Used to create a shared key with a remote device",
|
||||||
clickEvent(
|
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||||
setPrivateKey,
|
validationText: privateKeyValidationText,
|
||||||
privateKeyBitCount,
|
devicePSKBitCount: privateKeyBitCount,
|
||||||
setPrivateKeyValidationText,
|
inputChange: privateKeyInputChangeEvent,
|
||||||
),
|
selectChange: privateKeySelectChangeEvent,
|
||||||
disabledBy: [
|
hide: !privateKeyVisible,
|
||||||
{
|
buttonClick: privateKeyClickEvent,
|
||||||
fieldName: "adminChannelEnabled",
|
properties: {
|
||||||
invert: true,
|
value: privateKey,
|
||||||
},
|
action: {
|
||||||
],
|
icon: privateKeyVisible ? EyeOff : Eye,
|
||||||
properties: {
|
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
||||||
value: privateKey,
|
},
|
||||||
action: {
|
|
||||||
icon: privateKeyVisible ? EyeOff : Eye,
|
|
||||||
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
type: "text",
|
||||||
type: "text",
|
name: "publicKey",
|
||||||
name: "publicKey",
|
label: "Public Key",
|
||||||
label: "Public Key",
|
disabled: true,
|
||||||
disabled: true,
|
description:
|
||||||
description:
|
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||||
"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: "passwordGenerator",
|
|
||||||
name: "adminKey",
|
|
||||||
label: "Admin Key",
|
|
||||||
description:
|
|
||||||
"The public key authorized to send admin messages to this node",
|
|
||||||
validationText: adminKeyValidationText,
|
|
||||||
devicePSKBitCount: adminKeyBitCount,
|
|
||||||
inputChange: adminKeyInputChangeEvent,
|
|
||||||
selectChange: adminKeySelectChangeEvent,
|
|
||||||
hide: !adminKeyVisible,
|
|
||||||
buttonClick: () =>
|
|
||||||
clickEvent(
|
|
||||||
setAdminKey,
|
|
||||||
adminKeyBitCount,
|
|
||||||
setAdminKeyValidationText,
|
|
||||||
),
|
|
||||||
disabledBy: [{ fieldName: "adminChannelEnabled" }],
|
|
||||||
properties: {
|
|
||||||
value: adminKey,
|
|
||||||
action: {
|
|
||||||
icon: adminKeyVisible ? EyeOff : Eye,
|
|
||||||
onClick: () => setAdminKeyVisible(!adminKeyVisible),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
label: "Admin Settings",
|
||||||
label: "Logging Settings",
|
description: "Settings for Admin",
|
||||||
description: "Settings for Logging",
|
fields: [
|
||||||
fields: [
|
{
|
||||||
{
|
type: "toggle",
|
||||||
type: "toggle",
|
name: "adminChannelEnabled",
|
||||||
name: "bluetoothLoggingEnabled",
|
label: "Allow Legacy Admin",
|
||||||
label: "Allow Bluetooth Logging",
|
description:
|
||||||
description: "Enables device (serial style logs) over Bluetooth",
|
"Allow incoming device control over the insecure legacy admin channel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "debugLogApiEnabled",
|
name: "isManaged",
|
||||||
label: "Enable Debug Log API",
|
label: "Managed",
|
||||||
description: "Output live debug logging over serial",
|
description:
|
||||||
},
|
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
|
||||||
{
|
},
|
||||||
type: "toggle",
|
{
|
||||||
name: "serialEnabled",
|
type: "text",
|
||||||
label: "Serial Output Enabled",
|
name: "adminKey",
|
||||||
description: "Serial Console over the Stream API",
|
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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const ChannelChat = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="pl-3 pr-3 pt-3 pb-1">
|
||||||
<MessageInput to={to} channel={channel} />
|
<MessageInput to={to} channel={channel} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDevice } from "@app/core/stores/deviceStore.js";
|
import { useDevice } from "@app/core/stores/deviceStore.js";
|
||||||
import type { Protobuf } from "@meshtastic/js";
|
import type { Protobuf } from "@meshtastic/js";
|
||||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
|
||||||
export interface TraceRouteProps {
|
export interface TraceRouteProps {
|
||||||
from?: Protobuf.Mesh.NodeInfo;
|
from?: Protobuf.Mesh.NodeInfo;
|
||||||
@@ -24,7 +25,10 @@ export const TraceRoute = ({
|
|||||||
<div className="ml-5 flex">
|
<div className="ml-5 flex">
|
||||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||||
{to?.user?.longName}↔
|
{to?.user?.longName}↔
|
||||||
{route.map((hop) => `${nodes.get(hop)?.user?.longName ?? "Unknown"}↔`)}
|
{route.map((hop) => {
|
||||||
|
const node = nodes.get(hop);
|
||||||
|
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`;
|
||||||
|
})}
|
||||||
{from?.user?.longName}
|
{from?.user?.longName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
|
|||||||
description: "How often to send Power data over the mesh",
|
description: "How often to send Power data over the mesh",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "toggle",
|
||||||
name: "powerScreenEnabled",
|
name: "powerScreenEnabled",
|
||||||
label: "Power Screen Enabled",
|
label: "Power Screen Enabled",
|
||||||
description: "Enable the Power Telemetry Screen",
|
description: "Enable the Power Telemetry Screen",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cn } from "@app/core/utils/cn.js";
|
import { cn } from "@app/core/utils/cn.js";
|
||||||
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
|
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
|
||||||
|
import Footer from "./UI/Footer";
|
||||||
|
|
||||||
export interface PageLayoutProps {
|
export interface PageLayoutProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -7,6 +8,7 @@ export interface PageLayoutProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
actions?: {
|
actions?: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
iconClasses?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
@@ -18,40 +20,43 @@ export const PageLayout = ({
|
|||||||
children,
|
children,
|
||||||
}: PageLayoutProps): JSX.Element => {
|
}: PageLayoutProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-col">
|
<>
|
||||||
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
<div className="relative flex h-full w-full flex-col">
|
||||||
<button
|
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
||||||
type="button"
|
<button
|
||||||
className="pl-4 transition-all hover:text-accent md:hidden"
|
type="button"
|
||||||
>
|
className="pl-4 transition-all hover:text-accent md:hidden"
|
||||||
<AlignLeftIcon />
|
>
|
||||||
</button>
|
<AlignLeftIcon />
|
||||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
</button>
|
||||||
<div className="flex w-full items-center">
|
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
||||||
<span className="w-full text-lg font-medium">{label}</span>
|
<div className="flex w-full items-center">
|
||||||
<div className="flex justify-end space-x-4">
|
<span className="w-full text-lg font-medium">{label}</span>
|
||||||
{actions?.map((action, index) => (
|
<div className="flex justify-end space-x-4">
|
||||||
<button
|
{actions?.map((action, index) => (
|
||||||
key={action.icon.name}
|
<button
|
||||||
type="button"
|
key={action.icon.name}
|
||||||
className="transition-all hover:text-accent"
|
type="button"
|
||||||
onClick={action.onClick}
|
className="transition-all hover:text-accent"
|
||||||
>
|
onClick={action.onClick}
|
||||||
<action.icon />
|
>
|
||||||
</button>
|
<action.icon className={action.iconClasses} />
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-y-auto",
|
||||||
|
!noPadding && "pl-3 pr-3 ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</>
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col overflow-y-auto",
|
|
||||||
!noPadding && "p-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
|||||||
value: string;
|
value: string;
|
||||||
variant: "default" | "invalid";
|
variant: "default" | "invalid";
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
bits?: { text: string; value: string; key: string }[];
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
@@ -35,6 +36,11 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
|||||||
variant,
|
variant,
|
||||||
value,
|
value,
|
||||||
buttonText,
|
buttonText,
|
||||||
|
bits = [
|
||||||
|
{ text: "256 bit", value: "32", key: "bit256" },
|
||||||
|
{ text: "128 bit", value: "16", key: "bit128" },
|
||||||
|
{ text: "8 bit", value: "1", key: "bit8" },
|
||||||
|
],
|
||||||
selectChange,
|
selectChange,
|
||||||
inputChange,
|
inputChange,
|
||||||
buttonClick,
|
buttonClick,
|
||||||
@@ -44,6 +50,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -54,30 +75,29 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
|||||||
onChange={inputChange}
|
onChange={inputChange}
|
||||||
action={action}
|
action={action}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={devicePSKBitCount?.toString()}
|
value={devicePSKBitCount?.toString()}
|
||||||
onValueChange={(e) => selectChange(e)}
|
onValueChange={(e) => selectChange(e)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="!max-w-max">
|
<SelectTrigger className="!max-w-max">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem key="bit256" value="32">
|
{bits.map(({ text, value, key }) => (
|
||||||
256 bit
|
<SelectItem key={key} value={value}>
|
||||||
</SelectItem>
|
{text}
|
||||||
<SelectItem key="bit128" value="16">
|
</SelectItem>
|
||||||
128 bit
|
))}
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key="bit8" value="1">
|
|
||||||
8 bit
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="success"
|
variant="success"
|
||||||
onClick={buttonClick}
|
onClick={buttonClick}
|
||||||
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface InputProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, variant, prefix, suffix, action, ...props }, ref) => {
|
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
{prefix && (
|
{prefix && (
|
||||||
@@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className,
|
className,
|
||||||
inputVariants({ variant }),
|
inputVariants({ variant }),
|
||||||
)}
|
)}
|
||||||
|
value={value}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800",
|
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function convertIntToIpAddress(int: number): string {
|
||||||
|
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertIpAddressToInt(ip: string): number | null {
|
||||||
|
return (
|
||||||
|
ip
|
||||||
|
.split(".")
|
||||||
|
.reverse()
|
||||||
|
.reduce((ipnum, octet) => {
|
||||||
|
return (ipnum << 8) + Number.parseInt(octet);
|
||||||
|
}, 0) >>> 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -21,79 +21,81 @@ export const Dashboard = () => {
|
|||||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
<div className="space-y-1">
|
<div className="flex items-center justify-between">
|
||||||
<H3>Connected Devices</H3>
|
<div className="space-y-1">
|
||||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
<H3>Connected Devices</H3>
|
||||||
|
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||||
|
{devices.length ? (
|
||||||
|
<ul className="grow divide-y divide-gray-200">
|
||||||
|
{devices.map((device) => {
|
||||||
|
return (
|
||||||
|
<li key={device.id}>
|
||||||
|
<div className="px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="truncate text-sm font-medium text-accent">
|
||||||
|
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||||
|
?.longName ?? "UNK"}
|
||||||
|
</p>
|
||||||
|
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
||||||
|
{device.connection?.connType === "ble" && (
|
||||||
|
<>
|
||||||
|
<BluetoothIcon size={16} />
|
||||||
|
BLE
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{device.connection?.connType === "serial" && (
|
||||||
|
<>
|
||||||
|
<UsbIcon size={16} />
|
||||||
|
Serial
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{device.connection?.connType === "http" && (
|
||||||
|
<>
|
||||||
|
<NetworkIcon size={16} />
|
||||||
|
Network
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div className="flex gap-2 text-sm text-gray-500">
|
||||||
|
<UsersIcon
|
||||||
|
size={20}
|
||||||
|
className="text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="m-auto flex flex-col gap-3 text-center">
|
||||||
|
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||||
|
<H3>No Devices</H3>
|
||||||
|
<Subtle>Connect at least one device to get started</Subtle>
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => setConnectDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
New Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
|
||||||
{devices.length ? (
|
|
||||||
<ul className="grow divide-y divide-gray-200">
|
|
||||||
{devices.map((device) => {
|
|
||||||
return (
|
|
||||||
<li key={device.id}>
|
|
||||||
<div className="px-4 py-4 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="truncate text-sm font-medium text-accent">
|
|
||||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
|
||||||
?.longName ?? "UNK"}
|
|
||||||
</p>
|
|
||||||
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
|
||||||
{device.connection?.connType === "ble" && (
|
|
||||||
<>
|
|
||||||
<BluetoothIcon size={16} />
|
|
||||||
BLE
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{device.connection?.connType === "serial" && (
|
|
||||||
<>
|
|
||||||
<UsbIcon size={16} />
|
|
||||||
Serial
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{device.connection?.connType === "http" && (
|
|
||||||
<>
|
|
||||||
<NetworkIcon size={16} />
|
|
||||||
Network
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 sm:flex sm:justify-between">
|
|
||||||
<div className="flex gap-2 text-sm text-gray-500">
|
|
||||||
<UsersIcon
|
|
||||||
size={20}
|
|
||||||
className="text-gray-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div className="m-auto flex flex-col gap-3 text-center">
|
|
||||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
|
||||||
<H3>No Devices</H3>
|
|
||||||
<Subtle>Connect atleast one device to get started</Subtle>
|
|
||||||
<Button
|
|
||||||
className="gap-2"
|
|
||||||
onClick={() => setConnectDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
New Connection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,7 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
|
|||||||
import { useAppStore } from "@core/stores/appStore.js";
|
import { useAppStore } from "@core/stores/appStore.js";
|
||||||
import { useDevice } from "@core/stores/deviceStore.js";
|
import { useDevice } from "@core/stores/deviceStore.js";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { bbox, lineString } from "@turf/turf";
|
import { bbox, lineString } from "@turf/turf";
|
||||||
import {
|
import {
|
||||||
BoxSelectIcon,
|
BoxSelectIcon,
|
||||||
@@ -180,7 +181,8 @@ export const MapPage = (): JSX.Element => {
|
|||||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||||
<Hashicon value={node.num.toString()} size={22} />
|
<Hashicon value={node.num.toString()} size={22} />
|
||||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||||
{node.user?.longName}
|
{node.user?.longName ||
|
||||||
|
`!${numberToHexUnpadded(node.num)}`}
|
||||||
</Subtle>
|
</Subtle>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
+76
-54
@@ -7,8 +7,9 @@ import { useToast } from "@core/hooks/useToast.js";
|
|||||||
import { useDevice } from "@core/stores/deviceStore.js";
|
import { useDevice } from "@core/stores/deviceStore.js";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||||
import { Protobuf, Types } from "@meshtastic/js";
|
import { Protobuf, Types } from "@meshtastic/js";
|
||||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { getChannelName } from "@pages/Channels.js";
|
import { getChannelName } from "@pages/Channels.js";
|
||||||
import { HashIcon, WaypointsIcon } from "lucide-react";
|
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const MessagesPage = (): JSX.Element => {
|
export const MessagesPage = (): JSX.Element => {
|
||||||
@@ -28,6 +29,8 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
const currentChannel = channels.get(activeChat);
|
const currentChannel = channels.get(activeChat);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const node = nodes.get(activeChat);
|
||||||
|
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,7 +59,7 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
{filteredNodes.map((node) => (
|
{filteredNodes.map((node) => (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={node.num}
|
key={node.num}
|
||||||
label={node.user?.longName ?? "Unknown"}
|
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
|
||||||
active={activeChat === node.num}
|
active={activeChat === node.num}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("direct");
|
setChatType("direct");
|
||||||
@@ -67,60 +70,79 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
))}
|
))}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<PageLayout
|
<div className="flex flex-col flex-grow">
|
||||||
label={`Messages: ${
|
<PageLayout
|
||||||
chatType === "broadcast" && currentChannel
|
label={`Messages: ${
|
||||||
? getChannelName(currentChannel)
|
chatType === "broadcast" && currentChannel
|
||||||
: chatType === "direct" && nodes.get(activeChat)
|
? getChannelName(currentChannel)
|
||||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
: chatType === "direct" && nodes.get(activeChat)
|
||||||
: "Loading..."
|
? nodes.get(activeChat)?.user?.longName ?? nodeHex
|
||||||
}`}
|
: "Loading..."
|
||||||
actions={
|
}`}
|
||||||
chatType === "direct"
|
actions={
|
||||||
? [
|
chatType === "direct"
|
||||||
{
|
? [
|
||||||
icon: WaypointsIcon,
|
{
|
||||||
async onClick() {
|
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
const targetNode = nodes.get(activeChat)?.num;
|
? LockIcon
|
||||||
if (targetNode === undefined) return;
|
: LockOpenIcon,
|
||||||
toast({
|
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
title: "Sending Traceroute, please wait...",
|
? "text-green-600"
|
||||||
});
|
: "text-yellow-300",
|
||||||
await connection?.traceRoute(targetNode).then(() =>
|
async onClick() {
|
||||||
|
const targetNode = nodes.get(activeChat)?.num;
|
||||||
|
if (targetNode === undefined) return;
|
||||||
toast({
|
toast({
|
||||||
title: "Traceroute sent.",
|
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;
|
||||||
{allChannels.map(
|
toast({
|
||||||
(channel) =>
|
title: "Sending Traceroute, please wait...",
|
||||||
activeChat === channel.index && (
|
});
|
||||||
<ChannelChat
|
await connection?.traceRoute(targetNode).then(() =>
|
||||||
key={channel.index}
|
toast({
|
||||||
to="broadcast"
|
title: "Traceroute sent.",
|
||||||
messages={messages.broadcast.get(channel.index)}
|
}),
|
||||||
channel={channel.index}
|
);
|
||||||
/>
|
},
|
||||||
),
|
},
|
||||||
)}
|
]
|
||||||
{filteredNodes.map(
|
: []
|
||||||
(node) =>
|
}
|
||||||
activeChat === node.num && (
|
>
|
||||||
<ChannelChat
|
{allChannels.map(
|
||||||
key={node.num}
|
(channel) =>
|
||||||
to={activeChat}
|
activeChat === channel.index && (
|
||||||
messages={messages.direct.get(node.num)}
|
<ChannelChat
|
||||||
channel={Types.ChannelNumber.Primary}
|
key={channel.index}
|
||||||
traceroutes={traceroutes.get(node.num)}
|
to="broadcast"
|
||||||
/>
|
messages={messages.broadcast.get(channel.index)}
|
||||||
),
|
channel={channel.index}
|
||||||
)}
|
/>
|
||||||
</PageLayout>
|
),
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+80
-67
@@ -1,3 +1,4 @@
|
|||||||
|
import Footer from "@app/components/UI/Footer";
|
||||||
import { useAppStore } from "@app/core/stores/appStore";
|
import { useAppStore } from "@app/core/stores/appStore";
|
||||||
import { Sidebar } from "@components/Sidebar.js";
|
import { Sidebar } from "@components/Sidebar.js";
|
||||||
import { Button } from "@components/UI/Button.js";
|
import { Button } from "@components/UI/Button.js";
|
||||||
@@ -7,7 +8,8 @@ import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
|
|||||||
import { useDevice } from "@core/stores/deviceStore.js";
|
import { useDevice } from "@core/stores/deviceStore.js";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { base16 } from "rfc4648";
|
import { base16 } from "rfc4648";
|
||||||
|
|
||||||
@@ -27,73 +29,84 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="w-full overflow-y-auto">
|
<div className="flex flex-col w-full">
|
||||||
<Table
|
<div className="overflow-y-auto h-full">
|
||||||
headings={[
|
<Table
|
||||||
{ title: "", type: "blank", sortable: false },
|
headings={[
|
||||||
{ title: "Name", type: "normal", sortable: true },
|
{ title: "", type: "blank", sortable: false },
|
||||||
{ title: "Model", type: "normal", sortable: true },
|
{ title: "Name", type: "normal", sortable: true },
|
||||||
{ title: "MAC Address", type: "normal", sortable: true },
|
{ title: "Model", type: "normal", sortable: true },
|
||||||
{ title: "Last Heard", type: "normal", sortable: true },
|
{ title: "MAC Address", type: "normal", sortable: true },
|
||||||
{ title: "SNR", type: "normal", sortable: true },
|
{ title: "Last Heard", type: "normal", sortable: true },
|
||||||
{ title: "Connection", type: "normal", sortable: true },
|
{ title: "SNR", type: "normal", sortable: true },
|
||||||
{ title: "Remove", type: "normal", sortable: false },
|
{ title: "Encryption", type: "normal", sortable: false },
|
||||||
]}
|
{ title: "Connection", type: "normal", sortable: true },
|
||||||
rows={filteredNodes.map((node) => [
|
{ title: "Remove", type: "normal", sortable: false },
|
||||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
]}
|
||||||
<h1 key="header">
|
rows={filteredNodes.map((node) => [
|
||||||
{node.user?.longName ??
|
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||||
(node.user?.macaddr
|
<h1 key="header">
|
||||||
? `Meshtastic ${base16
|
{node.user?.longName ??
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
(node.user?.macaddr
|
||||||
.toLowerCase()}`
|
? `Meshtastic ${base16
|
||||||
: `UNK: ${node.num}`)}
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
</h1>,
|
.toLowerCase()}`
|
||||||
|
: `!${numberToHexUnpadded(node.num)}`)}
|
||||||
|
</h1>,
|
||||||
|
|
||||||
<Mono key="model">
|
<Mono key="model">
|
||||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Mono key="addr">
|
<Mono key="addr">
|
||||||
{base16
|
{base16
|
||||||
.stringify(node.user?.macaddr ?? [])
|
.stringify(node.user?.macaddr ?? [])
|
||||||
.match(/.{1,2}/g)
|
.match(/.{1,2}/g)
|
||||||
?.join(":") ?? "UNK"}
|
?.join(":") ?? "UNK"}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Fragment key="lastHeard">
|
<Fragment key="lastHeard">
|
||||||
{node.lastHeard === 0 ? (
|
{node.lastHeard === 0 ? (
|
||||||
<p>Never</p>
|
<p>Never</p>
|
||||||
) : (
|
) : (
|
||||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||||
)}
|
)}
|
||||||
</Fragment>,
|
</Fragment>,
|
||||||
<Mono key="snr">
|
<Mono key="snr">
|
||||||
{node.snr}db/
|
{node.snr}db/
|
||||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||||
{(node.snr + 10) * 5}raw
|
{(node.snr + 10) * 5}raw
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Mono key="hops">
|
<Mono key="pki">
|
||||||
{node.lastHeard !== 0
|
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||||
? node.viaMqtt === false && node.hopsAway === 0
|
<LockIcon className="text-green-600" />
|
||||||
? "Direct"
|
) : (
|
||||||
: `${node.hopsAway.toString()} ${
|
<LockOpenIcon className="text-yellow-300" />
|
||||||
node.hopsAway > 1 ? "hops" : "hop"
|
)}
|
||||||
} away`
|
</Mono>,
|
||||||
: "-"}
|
<Mono key="hops">
|
||||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
{node.lastHeard !== 0
|
||||||
</Mono>,
|
? node.viaMqtt === false && node.hopsAway === 0
|
||||||
<Button
|
? "Direct"
|
||||||
key="remove"
|
: `${node.hopsAway.toString()} ${
|
||||||
variant="destructive"
|
node.hopsAway > 1 ? "hops" : "hop"
|
||||||
onClick={() => {
|
} away`
|
||||||
setNodeNumToBeRemoved(node.num);
|
: "-"}
|
||||||
setDialogOpen("nodeRemoval", true);
|
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||||
}}
|
</Mono>,
|
||||||
>
|
<Button
|
||||||
<TrashIcon />
|
key="remove"
|
||||||
Remove
|
variant="destructive"
|
||||||
</Button>,
|
onClick={() => {
|
||||||
])}
|
setNodeNumToBeRemoved(node.num);
|
||||||
/>
|
setDialogOpen("nodeRemoval", true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Remove
|
||||||
|
</Button>,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,4 +60,7 @@ export class LoRaValidation
|
|||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
ignoreMqtt: boolean;
|
ignoreMqtt: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
configOkToMqtt: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,21 +41,24 @@ export class NetworkValidation
|
|||||||
|
|
||||||
export class NetworkValidationIpV4Config
|
export class NetworkValidationIpV4Config
|
||||||
implements
|
implements
|
||||||
Omit<Protobuf.Config.Config_NetworkConfig_IpV4Config, keyof Message>
|
Omit<
|
||||||
|
Protobuf.Config.Config_NetworkConfig_IpV4Config,
|
||||||
|
keyof Message | "ip" | "gateway" | "subnet" | "dns"
|
||||||
|
>
|
||||||
{
|
{
|
||||||
@IsIP()
|
@IsIP()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
ip: number;
|
ip: string;
|
||||||
|
|
||||||
@IsIP()
|
@IsIP()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
gateway: number;
|
gateway: string;
|
||||||
|
|
||||||
@IsIP()
|
@IsIP()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
subnet: number;
|
subnet: string;
|
||||||
|
|
||||||
@IsIP()
|
@IsIP()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dns: number;
|
dns: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,4 @@ export class PositionValidation
|
|||||||
|
|
||||||
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode)
|
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode)
|
||||||
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode;
|
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
channelPrecision: number[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,11 +1,10 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import { visualizer } from "rollup-plugin-visualizer";
|
import { visualizer } from "rollup-plugin-visualizer";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import EnvironmentPlugin from "vite-plugin-environment";
|
import EnvironmentPlugin from "vite-plugin-environment";
|
||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
let hash = "";
|
let hash = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user