Browse Source

Merge branch 'master' into feat/add-error-boundary

pull/458/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
d978978677
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 82
      .github/ISSUE_TEMPLATE/bug.yml
  2. 58
      .github/ISSUE_TEMPLATE/feature.yml
  3. 48
      .github/pull_request_template.md
  4. 3
      .github/workflows/ci.yml
  5. 48
      .github/workflows/format.yml
  6. 64
      .github/workflows/nightly.yml
  7. 3
      .github/workflows/pr.yml
  8. 3
      .github/workflows/release.yml
  9. 78
      README.md
  10. 697
      bun.lock
  11. 2
      index.html
  12. 12
      package.json
  13. 10
      src/components/Dialog/DeviceNameDialog.tsx
  14. 1
      src/components/Dialog/ImportDialog.tsx
  15. 10
      src/components/Dialog/NodeDetailsDialog.tsx
  16. 5
      src/components/Dialog/QRDialog.tsx
  17. 1
      src/components/Dialog/RebootDialog.tsx
  18. 1
      src/components/Dialog/ShutdownDialog.tsx
  19. 2
      src/components/Form/FormWrapper.tsx
  20. 234
      src/components/PageComponents/Config/Security/Security.tsx
  21. 37
      src/components/PageComponents/Config/Security/securityReducer.tsx
  22. 24
      src/components/PageComponents/Config/Security/types.ts
  23. 2
      src/components/PageComponents/Connect/HTTP.tsx
  24. 2
      src/components/PageComponents/Map/NodeDetail.tsx
  25. 8
      src/components/PageComponents/Messages/ChannelChat.tsx
  26. 10
      src/components/PageComponents/Messages/Message.tsx
  27. 4
      src/components/UI/Accordion.tsx
  28. 4
      src/components/UI/Button.tsx
  29. 6
      src/components/UI/Dialog.tsx
  30. 10
      src/components/UI/Input.tsx
  31. 64
      src/core/utils/debounce.test.ts
  32. 70
      src/core/utils/ip.test.ts
  33. 44
      src/core/utils/randId.test.ts
  34. 12
      src/core/utils/test.tsx
  35. 2
      src/index.css
  36. 8
      src/index.tsx
  37. 2
      src/pages/Config/DeviceConfig.tsx
  38. 8
      src/pages/Dashboard/index.tsx
  39. 2
      src/pages/Messages.tsx
  40. 5
      src/pages/Nodes.tsx
  41. 25
      vite.config.ts

82
.github/ISSUE_TEMPLATE/bug.yml

@ -6,8 +6,20 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
# Bug Report
Thanks for taking the time to fill out this bug report! The more information you provide, the faster we can diagnose and fix the issue.
- type: checkboxes
id: prerequisites
attributes:
label: Before submitting
description: Please confirm you've completed the following steps
options:
- label: I have searched existing issues to make sure this bug hasn't already been reported
required: true
- label: I have updated to the latest version of the software to verify the issue still exists
required: true
- label: I have cleared cache/cookies/storage or tried in a private/incognito window (if applicable)
required: false
- type: dropdown
id: hardware
attributes:
@ -41,7 +53,6 @@ body:
- Other
validations:
required: true
- type: dropdown
id: category
attributes:
@ -54,7 +65,6 @@ body:
- Serial
validations:
required: true
- type: dropdown
id: local
attributes:
@ -66,7 +76,6 @@ body:
- https://client.meshtastic.org
validations:
required: true
- type: input
id: version
attributes:
@ -75,15 +84,50 @@ body:
placeholder: x.x.x.yyyyyyy
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
description: What OS are you running? Include version if possible.
placeholder: e.g., Windows 11, macOS 13.1, Android 13, iOS 16.2
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: What browser are you using? Include version if possible.
placeholder: e.g., Chrome 108, Firefox 107, Safari 16.2
validations:
required: false
- type: textarea
id: body
id: expected
attributes:
label: Description
description: Please provide details on what steps you performed for this to happen.
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected to occur...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what occurred instead...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Provide clear steps to reproduce the issue
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: logs
attributes:
@ -92,3 +136,21 @@ body:
render: Shell
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context about the problem here.
validations:
required: false
- type: markdown
attributes:
value: |
Thank you for helping improve our project by reporting this bug!

58
.github/ISSUE_TEMPLATE/feature.yml

@ -6,12 +6,60 @@ body:
- type: markdown
attributes:
value: |
Thanks for your request this will not gurantee that we will implement it, but it will be reviewed.
Thanks for your request. While we can't guarantee implementation, all requests will be carefully reviewed.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm the following before submitting your feature request
options:
- label: I have searched existing issues to ensure this feature hasn't already been requested
required: true
- label: I have checked the documentation to verify this feature doesn't already exist
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem are you trying to solve? Describe the challenge or limitation you're facing.
placeholder: I'm frustrated when...
validations:
required: true
- type: textarea
id: body
id: solution
attributes:
label: Description
description: Please provide details about your enhancement.
label: Proposed Solution
description: Describe your idea for solving the problem. What would you like to see implemented?
placeholder: It would be great if...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Current Alternatives
description: Are there any workarounds or alternative solutions you're currently using?
placeholder: Currently, I'm working around this by...
validations:
required: false
- type: dropdown
id: importance
attributes:
label: Importance
description: How important is this feature to you?
options:
- Nice to have
- Important
- Critical
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, screenshots, mockups, or examples that might help us understand your request better.
validations:
required: false
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this feature request!

48
.github/pull_request_template.md

@ -0,0 +1,48 @@
<!--
Thank you for your contribution to our project! Please fill out the following template to help reviewers understand your changes.
-->
## Description
<!--
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
-->
## Related Issues
<!--
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
If there are no related issues, you can remove this section.
-->
## Changes Made
<!--
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
-->
-
-
-
-
## Testing Done
<!--
Describe how you tested these changes.
-->
## Screenshots (if applicable)
<!--
If your changes affect the UI, include screenshots or screencasts showing the before and after.
-->
## Checklist
<!--
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
-->
- [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added
- [ ] Tests have been added or updated
- [ ] All CI checks pass
- [ ] Dependent changes have been merged
## Additional Notes
<!--
Add any other context about the PR here.
-->

3
.github/workflows/ci.yml

@ -22,5 +22,8 @@ jobs:
- name: Install Dependencies
run: bun install
- name: Run tests
run: bun run test:run
- name: Build Package
run: bun run build

48
.github/workflows/format.yml

@ -1,48 +0,0 @@
name: Code Formatting
on:
pull_request:
branches: [ main, master ]
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Biome
run: npm install --global @biomejs/biome
- name: Format with Biome
run: biome format --write .
- name: Check for changes
id: git-check
run: |
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git add -A
git commit -m "chore: format code"
git push

64
.github/workflows/nightly.yml

@ -0,0 +1,64 @@
name: 'Nightly Release'
on:
schedule:
- cron: "0 5 * * *" # Run every day at 5am UTC
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Install Dependencies
run: bun install
- name: Run tests
run: bun run test:run
- name: Build Package
run: bun run build
- name: Package Output
run: bun run 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: nightly ${{ 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 }}"

3
.github/workflows/pr.yml

@ -14,6 +14,9 @@ jobs:
- name: Install Dependencies
run: bun install
- name: Run tests
run: bun run test:run
- name: Build Package
run: bun run build

3
.github/workflows/release.yml

@ -20,6 +20,9 @@ jobs:
- name: Install Dependencies
run: bun install
- name: Run tests
run: bun run test:run
- name: Build Package
run: bun run build

78
README.md

@ -17,6 +17,22 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
![Alt](https://repobeats.axiom.co/api/embed/e5b062db986cb005d83e81724c00cb2b9cce8e4c.svg "Repobeats analytics image")
## Progress Web App Support (PWA)
Meshtastic Web Client now includes Progressive Web App (PWA) functionality, allowing users to:
- Install the app on desktop and mobile devices
- Access the interface offline
- Receive updates automatically
- Experience faster load times with caching
To install as a PWA:
- On desktop: Look for the install icon in your browser's address bar
- On mobile: Use "Add to Home Screen" option in your browser menu
PWA functionality works with both the hosted version and self-hosted instances.
## Self-host
The client can be self hosted using the precompiled container images with an OCI compatible runtime such as [Docker](https://www.docker.com/) or [Podman](https://podman.io/).
@ -30,9 +46,71 @@ docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
```
## Nightly releases
Our nightly releases provide the latest development builds with cutting-edge features and fixes. These builds are automatically generated from the latest main branch every night and are available for testing and early adoption.
```bash
# With Docker
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly
#With Podman
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web:nightly
```
> [!WARNING]
> - Nightly builds represent the latest development state and may contain breaking changes
> - These builds undergo automated testing but may be less stable than tagged release versions
> - Not recommended for production environments unless you are actively testing new features
> - No guarantee of backward compatibility between nightly builds
### Version Information
Each nightly build is tagged with:
- The nightly tag for the latest build
- A specific SHA for build reproducibility
### Feedback
If you encounter any issues with nightly builds, please report them in our [issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps improve the stability of future releases
## Development & Building
You'll need to download the package manager used with this repo. You can install it by visiting [Bun.sh](https://bun.sh/) and following the installation instructions.
### Debugging
#### Debugging with React Scan
Meshtastic Web Client has included the library [React Scan](https://github.com/aidenybai/react-scan) to help you identify and resolve render performance issues during development.
React's comparison-by-reference approach to props makes it easy to inadvertently cause unnecessary re-renders, especially with:
- Inline function callbacks (`onClick={() => handleClick()}`)
- Object literals (`style={{ color: "purple" }}`)
- Array literals (`items={[1, 2, 3]}`)
These are recreated on every render, causing child components to re-render even when nothing has actually changed.
Unlike React DevTools, React Scan specifically focuses on performance optimization by:
- Clearly distinguishing between necessary and unnecessary renders
- Providing render counts for components
- Highlighting slow-rendering components
- Offering a dedicated performance debugging experience
#### Usage
When experiencing slow renders, run:
```bash
bun run dev:scan
```
This will allow you to discover the following about your components and pages:
- Components with excessive re-renders
- Performance bottlenecks in the render tree
- Expensive hook operations
- Props that change reference on every render
Use these insights to apply targeted optimizations like `React.memo()`, `useCallback()`, or `useMemo()` where they'll have the most impact.
### Building and Packaging
Build the project:

697
bun.lock

File diff suppressed because it is too large

2
index.html

@ -18,7 +18,7 @@
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
/>
<meta name="description" content="Meshtastic Web App" />
<meta name="description" content="Meshtastic Web Client" />
<title>Meshtastic Web</title>
</head>
<body>

12
package.json

@ -11,6 +11,10 @@
"check:fix": "pnpm check --write src/",
"format": "biome format --write src/",
"dev": "vite dev --open",
"dev:scan": "VITE_DEBUG_SCAN=true vite dev",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"preview": "vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)",
"postinstall": "npx simple-git-hooks"
@ -68,6 +72,7 @@
"react-hook-form": "^7.54.2",
"react-map-gl": "7.1.9",
"react-qrcode-logo": "^3.0.0",
"react-scan": "^0.2.4",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3"
@ -75,16 +80,19 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@tailwindcss/postcss": "^4.0.7",
"@testing-library/react": "^16.2.0",
"@types/chrome": "^0.0.304",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/serviceworker": "^0.0.122",
"@types/w3c-web-serial": "^1.0.7",
"@types/web-bluetooth": "^0.0.20",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"gzipper": "^8.2.0",
"happy-dom": "^17.1.4",
"postcss": "^8.5.1",
"simple-git-hooks": "^2.11.1",
"tailwind-merge": "^3.0.1",
@ -92,6 +100,8 @@
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"typescript": "^5.7.3",
"vite": "^6.1.1"
"vite": "^6.1.1",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.6"
}
}

10
src/components/Dialog/DeviceNameDialog.tsx

@ -26,7 +26,7 @@ export interface DeviceNameDialogProps {
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps): JSX.Element => {
}: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
@ -60,9 +60,13 @@ export const DeviceNameDialog = ({
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>Long Name</Label>
<Input {...register("longName")} />
<Input className="dark:text-slte-900" {...register("longName")} />
<Label>Short Name</Label>
<Input maxLength={4} {...register("shortName")} />
<Input
className="dark:text-slte-900"
maxLength={4}
{...register("shortName")}
/>
</form>
</div>
<DialogFooter>

1
src/components/Dialog/ImportDialog.tsx

@ -104,6 +104,7 @@ export const ImportDialog = ({
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}

10
src/components/Dialog/NodeDetailsDialog.tsx

@ -44,12 +44,12 @@ export const NodeDetailsDialog = ({
<DialogFooter>
<div className="w-full">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-gray-200 dark:border-gray-800"
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
}
/>
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Details:
</p>
@ -78,7 +78,7 @@ export const NodeDetailsDialog = ({
</div>
{device.position ? (
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Position:
</p>
@ -103,7 +103,7 @@ export const NodeDetailsDialog = ({
) : null}
{device.deviceMetrics ? (
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
@ -138,7 +138,7 @@ export const NodeDetailsDialog = ({
) : null}
{device ? (
<div className="mt-5 w-full max-w-[464px] bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>

5
src/components/Dialog/QRDialog.tsx

@ -100,7 +100,7 @@ export const QRDialog = ({
<div className="flex justify-center">
<button
type="button"
className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
@ -111,7 +111,7 @@ export const QRDialog = ({
</button>
<button
type="button"
className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
@ -127,6 +127,7 @@ export const QRDialog = ({
<Input
value={qrCodeUrl}
disabled={true}
className="dark:text-slate-900"
action={{
icon: ClipboardIcon,
onClick() {

1
src/components/Dialog/RebootDialog.tsx

@ -36,6 +36,7 @@ export const RebootDialog = ({
<div className="flex gap-2 p-4">
<Input
type="number"
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{

1
src/components/Dialog/ShutdownDialog.tsx

@ -39,6 +39,7 @@ export const ShutdownDialog = ({
type="number"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900"
suffix="Minutes"
/>
<Button

2
src/components/Form/FormWrapper.tsx

@ -22,7 +22,7 @@ export const FieldWrapper = ({
<Label>{label}</Label>
<div className="sm:col-span-2">
<div className="max-w-lg">
<p className="text-sm text-gray-500">{description}</p>
<p className="text-sm text-slate-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>

234
src/components/PageComponents/Config/Security.tsx → src/components/PageComponents/Config/Security/Security.tsx

@ -1,5 +1,6 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx";
import { useAppStore } from "@app/core/stores/appStore";
import {
getX25519PrivateKey,
getX25519PublicKey,
@ -9,117 +10,134 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import { useReducer } from "react";
import { securityReducer } from "./securityReducer";
export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
useDevice();
export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice();
const {
hasErrors,
getErrorMessage,
hasFieldError,
addError,
removeError,
clearErrors,
} = useAppStore();
const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [toByteArray(adminKey)],
privateKey: toByteArray(privateKey),
publicKey: toByteArray(publicKey),
},
},
}),
);
};
const [state, dispatch] = useReducer(securityReducer, {
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
privateKeyVisible: false,
adminKeyVisible: false,
privateKeyBitCount: config.security?.privateKey?.length ?? 32,
adminKeyBitCount: config.security?.adminKey?.at(0)?.length ?? 32,
publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
adminKey: fromByteArray(
config.security?.adminKey?.at(0) ?? new Uint8Array(0),
),
privateKeyDialogOpen: false,
});
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
fieldName: "privateKey" | "adminKey",
) => {
try {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
removeError(fieldName);
if (fieldName === "privateKey" && input === "") {
addError(fieldName, "Private Key is required");
return;
}
if (fieldName === "adminKey" && input === "") {
return;
}
if (input.length % 4 !== 0) {
addError(
fieldName,
`${fieldName === "privateKey" ? "Private" : "Admin"} Key is required to be a 256 bit pre-shared key (PSK)`,
);
return;
}
const decoded = toByteArray(input);
if (decoded.length !== count) {
addError(fieldName, `Please enter a valid ${count * 8} bit PSK`);
return;
}
} catch (e) {
console.error(e);
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
addError(
fieldName,
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"} Key format`,
);
}
};
const privateKeyClickEvent = () => {
setPrivateKeyDialogOpen(true);
};
const onSubmit = (data: SecurityValidation) => {
if (hasErrors()) {
return;
}
console.log(toByteArray(state.adminKey));
const pkiBackupClickEvent = () => {
setDialogOpen("pkiBackup", true);
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [new Uint8Array(0)],
privateKey: toByteArray(state.privateKey),
publicKey: toByteArray(state.publicKey),
},
},
}),
);
};
const pkiRegenerate = () => {
clearErrors();
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
setPrivateKey(fromByteArray(privateKey));
setPublicKey(fromByteArray(publicKey));
dispatch({
type: "REGENERATE_PRIV_PUB_KEY",
payload: {
privateKey: fromByteArray(privateKey),
publicKey: fromByteArray(publicKey),
},
});
validateKey(
fromByteArray(privateKey),
privateKeyBitCount,
setPrivateKeyValidationText,
state.privateKeyBitCount,
"privateKey",
);
setPrivateKeyDialogOpen(false);
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String });
validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey");
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
dispatch({ type: "SET_ADMIN_KEY", payload: psk });
validateKey(psk, state.privateKeyBitCount, "adminKey");
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validateKey(privateKey, count, setPrivateKeyValidationText);
dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count });
validateKey(state.privateKey, count, "privateKey");
};
return (
@ -130,9 +148,9 @@ export const Security = (): JSX.Element => {
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminKey: state.adminKey,
privateKey: state.privateKey,
publicKey: state.publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
@ -150,28 +168,35 @@ export const Security = (): JSX.Element => {
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,
validationText: hasFieldError("privateKey")
? getErrorMessage("privateKey")
: "",
devicePSKBitCount: state.privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
hide: !state.privateKeyVisible,
actionButtons: [
{
text: "Generate",
onClick: privateKeyClickEvent,
onClick: () =>
dispatch({
type: "SHOW_PRIVATE_KEY_DIALOG",
payload: true,
}),
variant: "success",
},
{
text: "Backup Key",
onClick: pkiBackupClickEvent,
onClick: () => setDialogOpen("pkiBackup", true),
variant: "subtle",
},
],
properties: {
value: privateKey,
value: state.privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
icon: state.privateKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
},
},
@ -183,7 +208,7 @@ export const Security = (): JSX.Element => {
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
value: state.publicKey,
},
},
],
@ -207,18 +232,47 @@ export const Security = (): JSX.Element => {
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
},
{
type: "text",
type: "passwordGenerator",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
validationText: hasFieldError("adminKey")
? getErrorMessage("adminKey")
: "",
inputChange: adminKeyInputChangeEvent,
selectChange: () => { },
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible,
actionButtons: [
{
text: "Generate",
variant: "success",
onClick: () => {
const adminKey = getX25519PrivateKey();
dispatch({
type: "REGENERATE_ADMIN_KEY",
payload: { adminKey: fromByteArray(adminKey) },
});
validateKey(
fromByteArray(adminKey),
state.adminKeyBitCount,
"adminKey",
);
},
},
],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
value: state.adminKey,
action: {
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
},
},
],
@ -245,9 +299,11 @@ export const Security = (): JSX.Element => {
]}
/>
<PkiRegenerateDialog
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
open={state.privateKeyDialogOpen}
onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })
}
onSubmit={pkiRegenerate}
/>
</>
);

37
src/components/PageComponents/Config/Security/securityReducer.tsx

@ -0,0 +1,37 @@
import type { SecurityAction, SecurityState } from "./types";
export function securityReducer(
state: SecurityState,
action: SecurityAction,
): SecurityState {
switch (action.type) {
case "SET_PRIVATE_KEY":
return { ...state, privateKey: action.payload };
case "TOGGLE_PRIVATE_KEY_VISIBILITY":
return { ...state, privateKeyVisible: !state.privateKeyVisible };
case "TOGGLE_ADMIN_KEY_VISIBILITY":
return { ...state, adminKeyVisible: !state.adminKeyVisible };
case "SET_PRIVATE_KEY_BIT_COUNT":
return { ...state, privateKeyBitCount: action.payload };
case "SET_PUBLIC_KEY":
return { ...state, publicKey: action.payload };
case "SET_ADMIN_KEY":
return { ...state, adminKey: action.payload };
case "SHOW_PRIVATE_KEY_DIALOG":
return { ...state, privateKeyDialogOpen: action.payload };
case "REGENERATE_PRIV_PUB_KEY":
return {
...state,
privateKey: action.payload.privateKey,
publicKey: action.payload.publicKey,
privateKeyDialogOpen: false,
};
case "REGENERATE_ADMIN_KEY":
return {
...state,
adminKey: action.payload.adminKey,
};
default:
return state;
}
}

24
src/components/PageComponents/Config/Security/types.ts

@ -0,0 +1,24 @@
export interface SecurityState {
privateKey: string;
privateKeyVisible: boolean;
adminKeyVisible: boolean;
privateKeyBitCount: number;
adminKeyBitCount: number;
publicKey: string;
adminKey: string;
privateKeyDialogOpen: boolean;
}
export type SecurityAction =
| { type: "SET_PRIVATE_KEY"; payload: string }
| { type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }
| { type: "TOGGLE_ADMIN_KEY_VISIBILITY" }
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number }
| { type: "SET_PUBLIC_KEY"; payload: string }
| { type: "SET_ADMIN_KEY"; payload: string }
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean }
| {
type: "REGENERATE_PRIV_PUB_KEY";
payload: { privateKey: string; publicKey: string };
}
| { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } };

2
src/components/PageComponents/Connect/HTTP.tsx

@ -57,7 +57,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Input
prefix={https ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
className="text-black dark:text-black"
className="text-slate-900 dark:text-slate-900"
disabled={connectionInProgress}
{...register("ip")}
/>

2
src/components/PageComponents/Map/NodeDetail.tsx

@ -121,7 +121,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex mt-2 text-sm">
<div className="flex items-center grow">
<div className="border-2 border-black rounded-sm px-0.5 mr-1">
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway}
</div>
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>

8
src/components/PageComponents/Messages/ChannelChat.tsx

@ -51,11 +51,11 @@ export const ChannelChat = ({
if (!messages?.length) {
return (
<div className="flex flex-col h-full w-full container mx-auto">
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="shrink-0 p-4 w-full dark:bg-gray-900">
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
@ -63,7 +63,7 @@ export const ChannelChat = ({
}
return (
<div className="flex flex-col h-full w-full container mx-auto">
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => {
@ -81,7 +81,7 @@ export const ChannelChat = ({
<div ref={messagesEndRef} className="w-full" />
</div>
</div>
<div className="shrink-0 mt-2 p-4 w-full dark:bg-gray-900">
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>

10
src/components/PageComponents/Messages/Message.tsx

@ -75,7 +75,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const isFailed = state === MESSAGE_STATES.FAILED;
const iconClass = cn(
className,
"text-gray-500 dark:text-gray-400 w-4 h-4 shrink-0",
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0",
);
const Icon = STATUS_ICON_MAP[state];
@ -99,7 +99,7 @@ const getMessageTextStyles = (state: MessageState) => {
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-gray-400",
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
@ -109,10 +109,10 @@ const TimeDisplay = ({
className,
}: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleDateString()}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
@ -148,7 +148,7 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.shortName} />
<div className="flex flex-col">
<span className="font-medium text-gray-900 dark:text-white truncate">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>

4
src/components/UI/Accordion.tsx

@ -16,7 +16,7 @@ export const AccordionTrigger = forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex justify-between items-center w-full p-4 border-b border-gray-200 dark:border-gray-800 group",
"flex justify-between items-center w-full p-4 border-b border-slat-200 dark:border-slat-800 group",
className,
)}
{...props}
@ -36,7 +36,7 @@ export const AccordionContent = forwardRef<
<AccordionPrimitive.Content
ref={ref}
className={cn(
"p-4 border-b border-gray-200 dark:border-gray-800",
"p-4 border-b border-slat-200 dark:border-slat-800",
className,
)}
{...props}

4
src/components/UI/Button.tsx

@ -9,7 +9,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-slate-900 text-white dark:bg-white dark:text-slate-900 bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-50",
"bg-slate-900 text-white dark:bg-slate-900 hover:dark:bg-slate-700 dark:text-slate-100 hover:bg-slate-800 ",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
@ -17,7 +17,7 @@ const buttonVariants = cva(
outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
subtle:
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 dark:bg-slate-700 dark:text-slate-100",
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",

6
src/components/UI/Dialog.tsx

@ -44,13 +44,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full bg-white max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full bg-white max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 dark:text-slate-900",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800">
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@ -105,7 +105,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500", "dark:text-slate-400", className)}
className={cn("text-sm text-slate-700", className)}
{...props}
/>
));

10
src/components/UI/Input.tsx

@ -5,7 +5,7 @@ import { type VariantProps, cva } from "class-variance-authority";
import type { LucideIcon } from "lucide-react";
const inputVariants = cva(
"flex h-10 w-full rounded-md border bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:open-dialog:text-slate-900",
{
variants: {
variant: {
@ -35,7 +35,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<div className="relative w-full">
{prefix && (
<span className="inline-flex items-center rounded-l-md bg-gray-100/90 px-3 font-mono text-sm text-slate-600">
<span className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600">
{prefix}
</span>
)}
@ -50,14 +50,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{suffix && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-slate-500">
<span className="text-gray-100/40 sm:text-sm">{suffix}</span>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-slate-500 dark:text-slate-900">
<span className="text-slate-100/40 sm:text-sm">{suffix}</span>
</div>
)}
{action && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-400 focus:outline-hidden "
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-400 focus:outline-hidden "
onClick={action.onClick}
>
<action.icon size={20} />

64
src/core/utils/debounce.test.ts

@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { debounce } from "./debounce";
describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delays executing the callback until after wait time has elapsed", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("only executes the callback once if called multiple times within wait period", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
debouncedFunction();
debouncedFunction();
vi.advanceTimersByTime(500);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("resets the timer when called again during wait period", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
vi.advanceTimersByTime(300);
debouncedFunction();
vi.advanceTimersByTime(300);
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("passes arguments to the callback function", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction("test", 123);
vi.advanceTimersByTime(500);
expect(mockCallback).toHaveBeenCalledWith("test", 123);
});
});

70
src/core/utils/ip.test.ts

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { convertIntToIpAddress, convertIpAddressToInt } from "./ip";
describe("IP Address Conversion Functions", () => {
describe("convertIntToIpAddress", () => {
it("converts 0 to 0.0.0.0", () => {
expect(convertIntToIpAddress(0)).toBe("0.0.0.0");
});
it("converts 16_777_343 to 127.0.0.1", () => {
expect(convertIntToIpAddress(16_777_343)).toBe("127.0.0.1");
});
it("converts 16_820_416 to 192.168.0.1", () => {
expect(convertIntToIpAddress(16_820_416)).toBe("192.168.0.1");
});
it("converts 4_294_967_295 to 255.255.255.255", () => {
expect(convertIntToIpAddress(4_294_967_295)).toBe("255.255.255.255");
});
});
describe("convertIpAddressToInt", () => {
it("converts 0.0.0.0 to 0", () => {
expect(convertIpAddressToInt("0.0.0.0")).toBe(0);
});
it("converts 127.0.0.1 to 16_777_343", () => {
expect(convertIpAddressToInt("127.0.0.1")).toBe(16_777_343);
});
it("converts 192.168.0.1 to 16_820_416", () => {
expect(convertIpAddressToInt("192.168.0.1")).toBe(16_820_416);
});
it("converts 255.255.255.255 to 4_294_967_295", () => {
expect(convertIpAddressToInt("255.255.255.255")).toBe(4_294_967_295);
});
it("handles non-standard formats", () => {
expect(convertIpAddressToInt("1.2.3.4")).toBe(67_305_985);
});
it("handles invalid IP addresses gracefully", () => {
expect(convertIpAddressToInt("300.1.2.3")).not.toBeNull();
expect(typeof convertIpAddressToInt("300.1.2.3")).toBe("number");
});
});
describe("bidirectional conversion", () => {
it("can convert back and forth", () => {
const testIps = [
"0.0.0.0",
"127.0.0.1",
"192.168.1.1",
"10.0.0.1",
"255.255.255.255",
];
for (const ip of testIps) {
const int = convertIpAddressToInt(ip);
expect(int).not.toBeNull();
if (int !== null) {
const convertedBack = convertIntToIpAddress(int);
expect(convertedBack).toBe(ip);
}
}
});
});
});

44
src/core/utils/randId.test.ts

@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { randId } from "./randId";
describe("randId", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns a number", () => {
const result = randId();
expect(typeof result).toBe("number");
});
it("returns an integer", () => {
const result = randId();
expect(Number.isInteger(result)).toBe(true);
});
it("uses Math.random to generate the number", () => {
const mockRandom = vi.spyOn(Math, "random").mockReturnValue(0.5);
const result = randId();
expect(mockRandom).toHaveBeenCalled();
expect(result).toBe(Math.floor(0.5 * 1e9));
});
it("returns a value between 0 and 1e9 (exclusive)", () => {
const result = randId();
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(1e9);
});
it("returns different values on subsequent calls", () => {
vi.spyOn(Math, "random").mockRestore();
const results = new Set();
for (let i = 0; i < 100; i++) {
results.add(randId());
}
expect(results.size).toBeGreaterThan(95);
});
});

12
src/core/utils/test.tsx

@ -0,0 +1,12 @@
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
function customRender(ui: ReactElement, options = {}) {
return render(ui, {
// wrapper: ({ children }) => <MapProvider>{children}</MapProvider>,
...options,
});
}
export * from "@testing-library/react";
export { customRender as render };

2
src/index.css

@ -74,7 +74,7 @@
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
border-color: var(--color-slate-200, currentColor);
}
body {

8
src/index.tsx

@ -1,3 +1,4 @@
import { scan } from "react-scan";
import "@app/index.css";
import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css";
@ -6,6 +7,13 @@ import { createRoot } from "react-dom/client";
import { App } from "@app/App.tsx";
// run react scan tool in development mode only
// react scan must be the first import and the first line in this file in order to work properly
import.meta.env.VITE_DEBUG_SCAN &&
scan({
enabled: true,
});
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);

2
src/pages/Config/DeviceConfig.tsx

@ -5,7 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
import { Network } from "@components/PageComponents/Config/Network.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "@components/PageComponents/Config/Security.tsx";
import { Security } from "@components/PageComponents/Config/Security/Security";
import {
Tabs,
TabsContent,

8
src/pages/Dashboard/index.tsx

@ -34,7 +34,7 @@ export const Dashboard = () => {
<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">
<ul className="grow divide-y divide-slate-200">
{devices.map((device) => {
return (
<li key={device.id}>
@ -72,10 +72,10 @@ export const Dashboard = () => {
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<div className="flex gap-2 text-sm text-slate-500">
<UsersIcon
size={20}
className="text-gray-400"
className="text-slate-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
@ -92,7 +92,7 @@ export const Dashboard = () => {
<Heading as="h3">No Devices</Heading>
<Subtle>Connect at least one device to get started</Subtle>
<Button
className="gap-2"
className="gap-2 dark:bg-white dark:text-slate-900 dark:hover:text-slate-100"
variant={"default"}
onClick={() => setConnectDialogOpen(true)}
>

2
src/pages/Messages.tsx

@ -62,7 +62,7 @@ export const MessagesPage = () => {
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-sm bg-white text-slate-900"
className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900"
/>
</div>
<div className="flex flex-col gap-4">

5
src/pages/Nodes.tsx

@ -63,9 +63,10 @@ const NodesPage = (): JSX.Element => {
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
setSelectedLocation(location);
},
[],
[hardware.myNodeNum],
);
return (
@ -78,7 +79,7 @@ const NodesPage = (): JSX.Element => {
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-sm bg-white text-slate-900"
className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900"
/>
</div>
<div className="overflow-y-auto h-full">

25
vite.config.ts

@ -1,5 +1,6 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { execSync } from 'node:child_process';
import path from 'path';
@ -11,7 +12,19 @@ try {
}
export default defineConfig({
plugins: [react()],
plugins: [react(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'generateSW',
devOptions: {
enabled: true
},
workbox: {
cleanupOutdatedCaches: true,
sourcemap: true
}
})
],
define: {
'process.env.COMMIT_HASH': JSON.stringify(hash),
},
@ -26,5 +39,13 @@ export default defineConfig({
},
server: {
port: 3000
},
optimizeDeps: {
exclude: ['react-scan']
},
test: {
environment: 'happy-dom',
globals: true,
include: ['**/*.{test,spec}.{ts,tsx}'],
}
});
Loading…
Cancel
Save