Merge branch 'master' into feat/add-error-boundary
This commit is contained in:
@@ -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: textarea
|
||||
id: body
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide details on what steps you performed for this to happen.
|
||||
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: expected
|
||||
attributes:
|
||||
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!
|
||||
@@ -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.
|
||||
|
||||
- type: textarea
|
||||
id: body
|
||||
Thanks for your request. While we can't guarantee implementation, all requests will be carefully reviewed.
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide details about your enhancement.
|
||||
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: solution
|
||||
attributes:
|
||||
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!
|
||||
@@ -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.
|
||||
-->
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,22 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+11
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ImportDialog = ({
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
className="dark:text-slate-900"
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+145
-89
@@ -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,34 +10,77 @@ 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 [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,
|
||||
fieldName: "privateKey" | "adminKey",
|
||||
) => {
|
||||
try {
|
||||
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);
|
||||
addError(
|
||||
fieldName,
|
||||
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"} Key format`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: SecurityValidation) => {
|
||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||
if (hasErrors()) {
|
||||
return;
|
||||
}
|
||||
console.log(toByteArray(state.adminKey));
|
||||
|
||||
setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
@@ -44,82 +88,56 @@ export const Security = (): JSX.Element => {
|
||||
case: "security",
|
||||
value: {
|
||||
...data,
|
||||
adminKey: [toByteArray(adminKey)],
|
||||
privateKey: toByteArray(privateKey),
|
||||
publicKey: toByteArray(publicKey),
|
||||
adminKey: [new Uint8Array(0)],
|
||||
privateKey: toByteArray(state.privateKey),
|
||||
publicKey: toByteArray(state.publicKey),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const validateKey = (
|
||||
input: string,
|
||||
count: number,
|
||||
setValidationText: (
|
||||
value: React.SetStateAction<string | undefined>,
|
||||
) => void,
|
||||
) => {
|
||||
try {
|
||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
||||
} else {
|
||||
setValidationText(undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
||||
}
|
||||
};
|
||||
|
||||
const privateKeyClickEvent = () => {
|
||||
setPrivateKeyDialogOpen(true);
|
||||
};
|
||||
|
||||
const pkiBackupClickEvent = () => {
|
||||
setDialogOpen("pkiBackup", true);
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } };
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
border-color: var(--color-slate-200, currentColor);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+3
-2
@@ -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">
|
||||
|
||||
+23
-2
@@ -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}'],
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user