mirror of
https://github.com/meshtastic/web.git
synced 2025-12-24 16:19:02 -05:00
Compare commits
60 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 | ||
|
|
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 | ||
|
|
6c1f140ad1 | ||
|
|
4c4be2e18f |
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 }}"
|
||||
@@ -23,7 +23,8 @@
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0",
|
||||
"@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-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -48,7 +49,7 @@
|
||||
"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.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -59,11 +60,12 @@
|
||||
"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.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/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
808
pnpm-lock.yaml
generated
808
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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 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}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -28,6 +29,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
<Generator
|
||||
hide={field.hide}
|
||||
devicePSKBitCount={field.devicePSKBitCount}
|
||||
bits={field.bits}
|
||||
inputChange={field.inputChange}
|
||||
selectChange={field.selectChange}
|
||||
buttonClick={field.buttonClick}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 cryptoRandomString from "crypto-random-string";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -15,7 +19,7 @@ export const Security = (): JSX.Element => {
|
||||
);
|
||||
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
|
||||
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
|
||||
config.security?.privateKey.length ?? 16,
|
||||
config.security?.privateKey.length ?? 32,
|
||||
);
|
||||
const [privateKeyValidationText, setPrivateKeyValidationText] =
|
||||
useState<string>();
|
||||
@@ -23,14 +27,11 @@ export const Security = (): JSX.Element => {
|
||||
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
||||
);
|
||||
const [adminKey, setAdminKey] = useState<string>(
|
||||
fromByteArray(config.security?.adminKey ?? new Uint8Array(0)),
|
||||
);
|
||||
const [adminKeyVisible, setAdminKeyVisible] = useState<boolean>(false);
|
||||
const [adminKeyBitCount, setAdminKeyBitCount] = useState<number>(
|
||||
config.security?.adminKey.length ?? 16,
|
||||
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;
|
||||
@@ -41,7 +42,7 @@ export const Security = (): JSX.Element => {
|
||||
case: "security",
|
||||
value: {
|
||||
...data,
|
||||
adminKey: toByteArray(adminKey),
|
||||
adminKey: [toByteArray(adminKey)],
|
||||
privateKey: toByteArray(privateKey),
|
||||
publicKey: toByteArray(publicKey),
|
||||
},
|
||||
@@ -50,191 +51,187 @@ export const Security = (): JSX.Element => {
|
||||
);
|
||||
};
|
||||
|
||||
const clickEvent = (
|
||||
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 = (
|
||||
const validateKey = (
|
||||
input: string,
|
||||
count: number,
|
||||
setValidationText: (
|
||||
value: React.SetStateAction<string | undefined>,
|
||||
) => 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.`);
|
||||
} 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 = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const psk = e.currentTarget?.value;
|
||||
setPrivateKey(psk);
|
||||
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText);
|
||||
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);
|
||||
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText);
|
||||
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
|
||||
};
|
||||
|
||||
const privateKeySelectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e);
|
||||
setPrivateKeyBitCount(count);
|
||||
validatePass(privateKey, count, setPrivateKeyValidationText);
|
||||
};
|
||||
|
||||
const adminKeySelectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e);
|
||||
setAdminKeyBitCount(count);
|
||||
validatePass(privateKey, count, setAdminKeyValidationText);
|
||||
validateKey(privateKey, count, setPrivateKeyValidationText);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<SecurityValidation>
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{
|
||||
...config.security,
|
||||
...{
|
||||
adminKey: adminKey,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
},
|
||||
}}
|
||||
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",
|
||||
validationText: privateKeyValidationText,
|
||||
devicePSKBitCount: privateKeyBitCount,
|
||||
inputChange: privateKeyInputChangeEvent,
|
||||
selectChange: privateKeySelectChangeEvent,
|
||||
hide: !privateKeyVisible,
|
||||
buttonClick: () =>
|
||||
clickEvent(
|
||||
setPrivateKey,
|
||||
privateKeyBitCount,
|
||||
setPrivateKeyValidationText,
|
||||
),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "adminChannelEnabled",
|
||||
invert: true,
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
value: privateKey,
|
||||
action: {
|
||||
icon: privateKeyVisible ? EyeOff : Eye,
|
||||
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
||||
<>
|
||||
<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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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),
|
||||
{
|
||||
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: "Logging Settings",
|
||||
description: "Settings for Logging",
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "bluetoothLoggingEnabled",
|
||||
label: "Allow Bluetooth Logging",
|
||||
description: "Enables device (serial style logs) over Bluetooth",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "debugLogApiEnabled",
|
||||
label: "Enable Debug Log API",
|
||||
description: "Output live debug logging over serial",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "serialEnabled",
|
||||
label: "Serial Output Enabled",
|
||||
description: "Serial Console over the Stream API",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
],
|
||||
},
|
||||
{
|
||||
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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ChannelChat = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="pl-3 pr-3 pt-3 pb-1">
|
||||
<MessageInput to={to} channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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, 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
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>;
|
||||
@@ -35,6 +36,11 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
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,
|
||||
@@ -44,6 +50,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
},
|
||||
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
|
||||
@@ -54,30 +75,29 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
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>
|
||||
<SelectItem key="bit256" value="32">
|
||||
256 bit
|
||||
</SelectItem>
|
||||
<SelectItem key="bit128" value="16">
|
||||
128 bit
|
||||
</SelectItem>
|
||||
<SelectItem key="bit8" value="1">
|
||||
8 bit
|
||||
</SelectItem>
|
||||
{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}
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface InputProps
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, variant, prefix, suffix, action, ...props }, ref) => {
|
||||
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{prefix && (
|
||||
@@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className,
|
||||
inputVariants({ variant }),
|
||||
)}
|
||||
value={value}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ 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, WaypointsIcon } from "lucide-react";
|
||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const MessagesPage = (): JSX.Element => {
|
||||
@@ -67,60 +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..."
|
||||
}`}
|
||||
actions={
|
||||
chatType === "direct"
|
||||
? [
|
||||
{
|
||||
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(() =>
|
||||
<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: "Traceroute sent.",
|
||||
}),
|
||||
);
|
||||
title: nodes.get(activeChat)?.user?.publicKey.length
|
||||
? "Chat is using PKI encryption."
|
||||
: "Chat is using PSK encryption.",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{
|
||||
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,3 +1,4 @@
|
||||
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";
|
||||
@@ -7,7 +8,7 @@ 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 { TrashIcon } from "lucide-react";
|
||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
@@ -27,73 +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 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>,
|
||||
<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 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="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>,
|
||||
])}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -60,4 +60,7 @@ export class LoRaValidation
|
||||
|
||||
@IsBoolean()
|
||||
ignoreMqtt: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
configOkToMqtt: boolean;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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