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

This commit is contained in:
Dan Ditomaso
2025-02-28 09:05:03 -05:00
committed by GitHub
41 changed files with 1503 additions and 212 deletions
+73 -11
View File
@@ -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!
+54 -6
View File
@@ -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!
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+686 -11
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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"
}
}
+7 -3
View File
@@ -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
View File
@@ -104,6 +104,7 @@ export const ImportDialog = ({
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}
+5 -5
View File
@@ -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>
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
@@ -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>
+2 -2
View File
@@ -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}
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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)}
>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}'],
}
});