Compare commits

..
35 changed files with 553 additions and 1393 deletions
-64
View File
@@ -1,64 +0,0 @@
# Git
.git
.gitignore
.github
# Dependencies
node_modules
app/node_modules
backend/node_modules
# Build artifacts
build
dist
app/dist
# Testing
coverage
.nyc_output
test-screenshot-*.png
ui-test.mp4
ui-test.gif
# Development
.vscode
.devcontainer
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE
*.swp
*.swo
*~
.idea
# Documentation
*.md
!Readme.md
LICENSE.md
# CI/CD files
.releaserc
appveyor.yml
# Misc
res
scripts
docker
icon.xcf
greenkeeper.json
prettier.config.js
.prettierignore
.eslintrc.json
.cspell.json
tslint.json
mcp.json
# Data directory (will be created in container)
data
-67
View File
@@ -1,72 +1,5 @@
# GitHub Copilot Agent Instructions for MQTT Explorer
## Style Guide
**Always follow the [Style Guide](../STYLE_GUIDE.md)** when making code changes. The style guide covers:
- Code formatting (Prettier + TSLint)
- TypeScript conventions
- React component patterns
- State management (Redux)
- File organization and naming
- Testing practices
- Security guidelines
## Test Suites
MQTT Explorer has several test suites to ensure code quality and reliability:
### Unit Tests
**App tests** - Frontend component and logic tests:
```bash
yarn test:app
# Or: cd app && yarn test
```
**Backend tests** - Data model and business logic tests:
```bash
yarn test:backend
# Or: cd backend && yarn test
```
**Run all unit tests**:
```bash
yarn test
```
### Integration Tests
**UI test suite** - Independent, deterministic browser tests:
```bash
yarn test:ui
# Requires: yarn build
```
**Demo video generation** - UI test recording with video capture:
```bash
yarn test:demo-video
# Requires: Xvfb, mosquitto broker, tmux, ffmpeg
# For development: Use ./scripts/uiTests.sh for full video recording setup
```
**MCP introspection tests** - Model Context Protocol tests:
```bash
yarn test:mcp
```
**Run all tests** (unit + demo-video):
```bash
yarn test:all
```
### CI/CD Test Execution
In CI environments, tests run in isolated containers with all dependencies pre-installed:
- `test` job: Runs unit tests (app + backend)
- `ui-tests` job: Runs UI test suite with screenshots
- `demo-video` job: Generates demo video with full recording setup
- `test-browser` job: Runs browser mode smoke tests
## Debugging Browser Mode
### Prerequisites
-219
View File
@@ -1,219 +0,0 @@
name: Docker Browser Build
on:
push:
branches:
- master
- release
- beta
paths:
- 'Dockerfile.browser'
- 'src/server.ts'
- 'src/AuthManager.ts'
- 'app/**'
- 'backend/**'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/docker-browser.yml'
- 'tsconfig.json'
- 'events/**'
schedule:
# Run every two weeks (1st and 15th of each month) at 2:00 AM UTC
- cron: '0 2 1,15 * *'
workflow_dispatch:
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
services:
# MQTT broker for testing
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883
options: >-
--health-cmd "mosquitto_sub -t '$SYS/#' -C 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.browser
platforms: linux/amd64
push: false
load: true
tags: mqtt-explorer:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image - Basic startup
run: |
# Start container with test credentials
docker run -d \
--name mqtt-explorer-test \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
-e PORT=3000 \
mqtt-explorer:test
# Wait for server to be ready (max 60 seconds)
echo "Waiting for server to start..."
for i in {1..60}; do
if curl -f http://localhost:3000 > /dev/null 2>&1; then
echo "Server started successfully after $i seconds"
break
fi
if [ $i -eq 60 ]; then
echo "Server failed to start within 60 seconds"
docker logs mqtt-explorer-test
exit 1
fi
sleep 1
done
- name: Test Docker image - Health check
run: |
# Wait for health check to pass
echo "Waiting for health check to pass..."
for i in {1..30}; do
health=$(docker inspect --format='{{.State.Health.Status}}' mqtt-explorer-test)
if [ "$health" = "healthy" ]; then
echo "Container is healthy"
break
fi
if [ $i -eq 30 ]; then
echo "Health check failed"
docker logs mqtt-explorer-test
exit 1
fi
sleep 2
done
- name: Test Docker image - Verify response
run: |
# Test that the server responds with HTML
response=$(curl -s http://localhost:3000)
if echo "$response" | grep -q "MQTT Explorer"; then
echo "Server is serving the application correctly"
else
echo "Server response does not contain expected content"
echo "Response: $response"
exit 1
fi
- name: Test Docker image - Verify data persistence
run: |
# Check that data directory was created
docker exec mqtt-explorer-test sh -c '[ -d /app/data ] && echo "Data directory exists"'
- name: Clean up test container
if: always()
run: |
docker stop mqtt-explorer-test || true
docker rm mqtt-explorer-test || true
- name: Check Docker image size
run: |
echo "### Docker Image Size" >> $GITHUB_STEP_SUMMARY
docker images mqtt-explorer:test --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" >> $GITHUB_STEP_SUMMARY
# Get size in bytes for detailed reporting
SIZE_BYTES=$(docker inspect mqtt-explorer:test --format='{{.Size}}')
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image size**: ${SIZE_MB} MB (${SIZE_BYTES} bytes)" >> $GITHUB_STEP_SUMMARY
echo "Image size: ${SIZE_MB} MB"
- name: Setup Node.js for browser tests
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'yarn'
- name: Install dependencies for browser tests
run: yarn install --frozen-lockfile
- name: Start Docker container for browser tests
run: |
docker run -d \
--name mqtt-explorer-browser-test \
--network host \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
-e PORT=3000 \
mqtt-explorer:test
# Wait for server to be ready
echo "Waiting for Docker container to be ready..."
timeout 60 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
echo "Docker container is ready"
- name: Run browser test suite
run: |
yarn test:browser
env:
MQTT_EXPLORER_USERNAME: test
MQTT_EXPLORER_PASSWORD: test123
BROWSER_MODE_URL: http://localhost:3000
MQTT_BROKER_HOST: localhost
MQTT_BROKER_PORT: 1883
- name: Clean up browser test container
if: always()
run: |
docker logs mqtt-explorer-browser-test || true
docker stop mqtt-explorer-browser-test || true
docker rm mqtt-explorer-browser-test || true
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.browser
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
- name: Test
run: yarn test
electron-tests:
ui-tests:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
@@ -36,14 +36,14 @@ jobs:
run: yarn install --frozen-lockfile
- name: Build
run: yarn build
- name: Run Electron UI Tests
- name: Run UI Tests
timeout-minutes: 10
run: ./scripts/runUiTests.sh
- name: Upload Test Screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: electron-test-screenshots
name: ui-test-screenshots
path: |
test-screenshot-*.png
retention-days: 30
-77
View File
@@ -10,54 +10,6 @@ MQTT Explorer uses GitHub Actions for continuous integration and testing. The pi
This workflow runs on pull requests to `master`, `beta`, and `release` branches.
### Docker Browser Build Workflow (`.github/workflows/docker-browser.yml`)
This workflow builds and publishes a Docker image for the browser mode.
**Triggers**:
- Push to `master`, `beta`, or `release` branches (when relevant files change)
- Schedule: Runs every two weeks (1st and 15th of each month at 2:00 AM UTC)
- Manual trigger via workflow_dispatch
**Platforms**:
- linux/amd64 (x86-64)
- linux/arm64 (Raspberry Pi 3/4/5, Apple Silicon)
- linux/arm/v7 (Raspberry Pi 2/3)
**Image Registry**: GitHub Container Registry (ghcr.io/thomasnordquist/mqtt-explorer)
**Tags**:
- `latest` - Latest build from master branch
- `master` - Latest build from master
- `beta` - Latest build from beta branch
- `release` - Latest build from release branch
- `<branch>-<sha>` - Specific commit builds
**Steps**:
1. Build Docker image with multi-stage build
2. Test basic startup with test credentials
3. Test health check
4. Verify HTTP response
5. Test data directory creation
6. Check Docker image size
7. Start container for frontend tests
8. Test frontend bundles (app.bundle.js, vendors.bundle.js)
9. Push image to GitHub Container Registry
10. Generate build attestation for supply chain security
**Image Features**:
- Multi-stage build for minimal size
- Alpine Linux base with Node.js 24 (~200MB final image)
- Multi-platform support (amd64, arm64, arm/v7)
- Non-root user (UID 1001)
- Health check endpoint
- Proper signal handling with dumb-init
- Persistent data volume at `/app/data`
### Test Workflow (`.github/workflows/tests.yml`)
This workflow runs on pull requests to `master`, `beta`, and `release` branches.
#### Jobs
##### 1. `test` - Electron Mode Tests
@@ -140,35 +92,6 @@ Example:
## Local Testing
### Docker Browser Mode
```bash
# Build the image locally (for your platform)
docker build -f Dockerfile.browser -t mqtt-explorer:local .
# Build for specific platform (e.g., Raspberry Pi)
docker buildx build --platform linux/arm64 -f Dockerfile.browser -t mqtt-explorer:local-arm64 .
# Run the container
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
mqtt-explorer:local
# Test the server
curl http://localhost:3000
# Check logs
docker logs <container-id>
# Stop and remove
docker stop <container-id>
docker rm <container-id>
```
See [DOCKER.md](DOCKER.md) for complete documentation.
### Electron Mode
```bash
-250
View File
@@ -1,250 +0,0 @@
# MQTT Explorer - Docker Browser Mode
Docker image for running MQTT Explorer in browser mode.
## Try It Now
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
Click the badge above to instantly try MQTT Explorer in your browser using Play with Docker (requires free Docker Hub account).
## Quick Start
### Using Pre-built Image
Pull and run the latest image from GitHub Container Registry:
```bash
docker pull ghcr.io/thomasnordquist/mqtt-explorer:latest
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=your_secure_password \
-v mqtt-explorer-data:/app/data \
--name mqtt-explorer \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
Access the application at `http://localhost:3000`
### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
mqtt-explorer:
image: ghcr.io/thomasnordquist/mqtt-explorer:latest
ports:
- "3000:3000"
environment:
- MQTT_EXPLORER_USERNAME=admin
- MQTT_EXPLORER_PASSWORD=your_secure_password
- PORT=3000
volumes:
- mqtt-explorer-data:/app/data
restart: unless-stopped
volumes:
mqtt-explorer-data:
```
Then run:
```bash
docker-compose up -d
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `MQTT_EXPLORER_USERNAME` | No | Generated | Username for authentication |
| `MQTT_EXPLORER_PASSWORD` | No | Generated | Password for authentication |
| `MQTT_EXPLORER_SKIP_AUTH` | No | `false` | Set to `true` to disable authentication (use only behind a secure proxy!) |
| `PORT` | No | `3000` | Port the server listens on |
| `ALLOWED_ORIGINS` | No | `*` | Comma-separated list of allowed CORS origins |
| `NODE_ENV` | No | - | Set to `production` for production deployments |
### Authentication Modes
**Standard Mode (Default):**
- Requires username and password for access
- Credentials can be set via environment variables or auto-generated
- Auto-generated credentials are logged on first startup and saved to `/app/data/credentials.json`
**Skip Authentication Mode (Use with caution!):**
```bash
docker run -d -p 3000:3000 \
-e MQTT_EXPLORER_SKIP_AUTH=true \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
⚠️ **WARNING**: When `MQTT_EXPLORER_SKIP_AUTH=true`, the application is **completely open** without any authentication. This should **only be used** when MQTT Explorer is deployed behind a secure authentication proxy (e.g., OAuth2 Proxy, Authelia, Nginx with auth_request) or in a trusted private network.
**Recommended use case**: Integration with enterprise SSO systems where authentication is handled by a reverse proxy.
**Note**: If credentials are not provided and auth is not skipped, they will be auto-generated and stored in `/app/data/credentials.json`. Check the container logs to see the generated credentials:
```bash
docker logs mqtt-explorer
```
## Data Persistence
The container stores data in `/app/data`, including:
- User credentials (`credentials.json`)
- Connection settings (`settings.json`)
- Uploaded certificates (`certificates/`)
- File uploads (`uploads/`)
Mount a volume to persist data across container restarts:
```bash
docker run -v mqtt-explorer-data:/app/data ...
```
## Building from Source
Build the Docker image locally:
```bash
docker build -f Dockerfile.browser -t mqtt-explorer:local .
```
Run the locally built image:
```bash
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=secret \
mqtt-explorer:local
```
## Health Check
The container includes a health check that runs every 30 seconds. Check the health status:
```bash
docker inspect --format='{{.State.Health.Status}}' mqtt-explorer
```
## Security Best Practices
1. **Use HTTPS in Production**: Put the container behind a reverse proxy (nginx, Traefik) with HTTPS
2. **Set Strong Credentials**: Always set custom credentials via environment variables
3. **Network Isolation**: Run in a private network when possible
4. **Update Regularly**: Pull the latest image regularly for security updates
### Example with Nginx Reverse Proxy
```nginx
server {
listen 443 ssl http2;
server_name mqtt-explorer.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Troubleshooting
### Container won't start
Check the logs:
```bash
docker logs mqtt-explorer
```
### Can't access the application
1. Verify the container is running: `docker ps`
2. Check the port mapping: `docker port mqtt-explorer`
3. Test connectivity: `curl http://localhost:3000`
### Authentication issues
1. Check generated credentials in logs: `docker logs mqtt-explorer`
2. Verify environment variables: `docker inspect mqtt-explorer`
3. Reset credentials by removing the data volume and restarting
### Permission issues
The container runs as a non-root user (UID 1001). If mounting host directories, ensure they're writable:
```bash
chown -R 1001:1001 /path/to/host/data
docker run -v /path/to/host/data:/app/data ...
```
## Available Tags
- `latest` - Latest stable version from the master branch
- `master` - Latest build from master branch
- `beta` - Latest beta version
- `release` - Latest release version
- `master-<sha>` - Specific commit from master
- `beta-<sha>` - Specific commit from beta
- `release-<sha>` - Specific commit from release
## Supported Platforms
The Docker image is built for multiple architectures:
- `linux/amd64` - x86-64 (standard PCs, servers)
- `linux/arm64` - ARM 64-bit (Raspberry Pi 3/4/5, Apple Silicon)
- `linux/arm/v7` - ARM 32-bit (Raspberry Pi 2/3)
## One-Click Deployment Options
### Play with Docker (Free)
Try MQTT Explorer instantly in your browser without installing anything:
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
- **No installation required** - Runs entirely in your browser
- **Free to use** - Requires only a Docker Hub account
- **Perfect for demos** - Great for testing and demonstrations
- **4-hour sessions** - Sessions automatically expire after 4 hours
### Cloud Platforms
Deploy MQTT Explorer to various cloud platforms with one click:
#### DigitalOcean App Platform
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/thomasnordquist/MQTT-Explorer/tree/master&refcode=docker)
- Automatically detects Docker configuration
- Managed platform with auto-scaling
- Starting at $5/month
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=docker&name=mqtt-explorer&image=ghcr.io/thomasnordquist/mqtt-explorer:latest&ports=3000;http;/)
- Deploy directly from Docker image
- Global edge network
- Free tier available
**Note:** Remember to set the environment variables `MQTT_EXPLORER_USERNAME` and `MQTT_EXPLORER_PASSWORD` when deploying to cloud platforms.
## License
See the main [LICENSE.md](LICENSE.md) file.
-81
View File
@@ -1,81 +0,0 @@
# Multi-stage build for MQTT Explorer Browser Mode
# Stage 1: Build
FROM node:24-alpine AS builder
WORKDIR /build
# Copy package files for dependency installation
COPY package.json yarn.lock ./
COPY app/package.json ./app/
COPY backend/package.json ./backend/
# Install ALL dependencies (needed for build)
RUN yarn install --frozen-lockfile --network-timeout 100000
# Copy source files
COPY tsconfig.json ./
COPY src ./src
COPY backend ./backend
COPY events ./events
COPY app ./app
# Build the application (compiles TypeScript and webpack bundles)
RUN yarn build:server
# Stage 2: Production dependencies
FROM node:24-alpine AS deps
WORKDIR /deps
# Copy only package files
COPY --from=builder /build/package.json /build/yarn.lock ./
# Install ONLY production dependencies
RUN yarn install --production --frozen-lockfile --network-timeout 100000 && \
yarn cache clean && \
rm -rf /tmp/*
# Stage 3: Production
FROM node:24-alpine
# Install dumb-init in a single layer
RUN apk add --no-cache dumb-init
# Create app user in a single layer
RUN addgroup -g 1001 -S mqttexplorer && \
adduser -u 1001 -S mqttexplorer -G mqttexplorer
WORKDIR /app
# Copy ONLY the compiled dist folder (contains compiled TypeScript)
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/dist ./dist
# Copy ONLY the built frontend app
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/app/build ./app/build
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/app/index.html ./app/
# Copy runtime node_modules (minimal set)
COPY --from=deps --chown=mqttexplorer:mqttexplorer /deps/node_modules ./node_modules
# Copy package.json for version info (needed by server)
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/package.json ./
# Create data directory for persistent storage
RUN mkdir -p /app/data && \
chown -R mqttexplorer:mqttexplorer /app/data
# Switch to non-root user
USER mqttexplorer
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "const http = require('http'); const req = http.get('http://localhost:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1)); req.on('error', () => process.exit(1));"
# Use dumb-init to handle signals properly
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Start the server
CMD ["node", "dist/src/server.js"]
+7 -83
View File
@@ -54,27 +54,6 @@ yarn start:server
Then open your browser to `http://localhost:3000`. For more details, see [BROWSER_MODE.md](BROWSER_MODE.md).
### Docker (Browser Mode)
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
Run MQTT Explorer in a Docker container:
```bash
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=your_secure_password \
-v mqtt-explorer-data:/app/data \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
**Supports multiple platforms**: amd64, arm64 (Raspberry Pi 3/4/5), arm/v7 (Raspberry Pi 2/3).
**Enterprise integration**: Set `MQTT_EXPLORER_SKIP_AUTH=true` to disable built-in authentication when deploying behind a secure authentication proxy (e.g., OAuth2 Proxy, SSO).
For complete Docker documentation including authentication options, deployment examples, and security best practices, see [DOCKER.md](DOCKER.md).
## Develop
### Desktop Application
@@ -99,55 +78,14 @@ yarn dev:server
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management, `src` contains all the electron bindings. [mqttjs](https://github.com/mqttjs/MQTT.js) is used to facilitate communication to MQTT brokers.
For coding standards, design patterns, and best practices, see the [Style Guide](STYLE_GUIDE.md).
## Automated Tests
MQTT Explorer uses multiple test suites to ensure reliability and quality:
To achieve a reliable product automated tests run regularly on CI.
### Unit Tests
**App tests** - Frontend component and logic tests:
```bash
yarn test:app
```
**Backend tests** - Data model and business logic tests:
```bash
yarn test:backend
```
**Run all unit tests**:
```bash
yarn test
```
### Integration & UI Tests
**UI test suite** - Independent, deterministic browser tests:
```bash
yarn build
yarn test:ui
```
**Demo video generation** - UI test recording for documentation:
```bash
yarn build
yarn test:demo-video
```
Note: Requires Xvfb, mosquitto broker, tmux, and ffmpeg. For full video recording setup, use `./scripts/uiTests.sh`.
**MCP introspection tests** - Model Context Protocol validation:
```bash
yarn build
yarn test:mcp
```
**Run all tests** (unit tests + demo video):
```bash
yarn build
yarn test:all
```
- **Data model tests**: `yarn test:backend`
- **App tests**: `yarn test:app`
- **UI test suite**: `yarn test:ui` (independent, deterministic tests)
- **Demo video**: `yarn ui-test` (UI test recording for documentation)
### Run UI Test Suite
@@ -166,27 +104,13 @@ See [docs/UI-TEST-SUITE.md](docs/UI-TEST-SUITE.md) for more details.
### Run Demo Video Generation
The demo video is used for documentation and showcases key features. It requires additional dependencies:
A [mosquitto](https://mosquitto.org/) MQTT broker is required to generate the demo video.
```bash
yarn build
yarn test:demo-video
yarn ui-test
```
**Requirements:**
- mosquitto MQTT broker
- Xvfb (virtual framebuffer)
- tmux (terminal multiplexer)
- ffmpeg (video encoding)
**For full video recording with post-processing:**
```bash
yarn build
./scripts/uiTests.sh
```
This script handles Xvfb setup, mosquitto startup, video recording, and cleanup.
## Create a release
Create a PR to `release` branch.
-183
View File
@@ -1,183 +0,0 @@
# MQTT Explorer Style Guide
Coding standards and patterns for the MQTT Explorer project, optimized for coding agents.
## Code Formatting
**Prettier config:** No semicolons, single quotes, 2 spaces, 120 char max, ES5 trailing commas
**TSLint:** Airbnb base, generic arrays `Array<T>`, explicit access modifiers (`public`/`private`/`protected`)
```typescript
// Format example
const name = 'MQTT Explorer'
const items: Array<string> = []
class Example {
private client: MqttClient
public connect(options: MqttOptions) {}
}
```
**Commands:** `yarn lint`, `yarn lint:fix`
## TypeScript
- Strict mode enabled (`noImplicitAny`, `strictNullChecks`)
- Explicit function types: `function name(param: Type): ReturnType {}`
- Avoid `any`, use interfaces for objects, enums for constants
- Type imports: `import type { Type } from './module'`
```typescript
enum ActionTypes {
CONNECTION_SET_CONNECTING = 'CONNECTION_SET_CONNECTING',
}
interface ConnectionState {
connected: boolean
error?: string
}
```
## React Components
**Class components:** Use `React.PureComponent`, type props/state, mark methods `public`/`private`
```typescript
interface Props {
connectionId: string
actions: typeof globalActions
}
class App extends React.PureComponent<Props, {}> {
public render() {
return <div />
}
}
```
**Functional components:** Destructure props, use custom hooks, keep under 200 lines
```typescript
function Sidebar(props: Props) {
const { tree, nodePath } = props
const node = usePollingToFetchTreeNode(tree, nodePath)
return <div />
}
```
**Hooks:** Prefix with `use`, place in `hooks/` or `helper/`, always specify `useEffect` dependencies
**Lazy loading:** `const Component = React.lazy(() => import('./Component'))`
**Material-UI:** Use both `ThemeProvider` and `LegacyThemeProvider`, `withStyles` for class components
## State Management (Redux)
**Reducers:** Define state interface, use enums for action types, use `createReducer` helper, return new objects
```typescript
export const reducer = createReducer<State>(initialState, {
[ActionTypes.ACTION]: (state, action) => ({ ...state, field: value }),
})
```
**Actions:** Use thunk for async, export action creators
```typescript
export const connect = (options: MqttOptions, id: string) =>
(dispatch: Dispatch<any>, getState: () => AppState) => {
dispatch(connecting(id))
}
```
**Connect:** `mapStateToProps`, `mapDispatchToProps` with `bindActionCreators`
## File Organization
```
app/src/
actions/ # Redux actions
components/ # React components (PascalCase folders)
reducers/ # Redux reducers
hooks/ # Custom hooks
model/ # Frontend models
utils/ # Utilities
backend/src/
Model/ # Core data models
DataSource/ # MQTT sources
src/
electron.ts # Electron main
server.ts # Express server
```
**Component structure:**
```
ComponentName/
index.ts # Re-exports
ComponentName.tsx
SubComponent.tsx
```
**File naming:** Components = PascalCase, utilities = camelCase, tests = `*.spec.ts`, hooks = `useHookName.tsx`
## Naming Conventions
- Variables/functions: `camelCase`
- Classes/interfaces: `PascalCase`
- Constants: `SCREAMING_SNAKE_CASE`
- Booleans: `isX`, `hasX`, `shouldX`, `canX`
- Props/State interfaces: `Props`, `State`
## Testing
**Framework:** Mocha + Chai
**Location:** `app/test/`, `backend/src/spec/`, `src/spec/`, co-located `*.spec.tsx`
```typescript
describe('Component', () => {
it('does something', () => {
expect(result).to.eq(expected)
})
})
```
**Commands:** `yarn test` (all), `yarn test:app`, `yarn test:backend`, `yarn test:ui` (needs build)
## Security
- Never hardcode credentials, use `process.env`
- Validate/sanitize all user input
- Use helmet.js, rate limiting, bcrypt, HTTPS in production
- See [SECURITY.md](SECURITY.md)
## Documentation
- JSDoc for public APIs
- Comments only for complex logic, workarounds, TODOs
- No obvious/redundant comments
## Development
**Workflow:**
- `yarn dev` - Electron dev mode
- `yarn dev:server` - Browser mode with hot reload (port 8080)
- `yarn build` / `yarn build:server`
- `yarn lint` / `yarn lint:fix`
**Git:** Feature branches from `master`, conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
**Performance:** Use `PureComponent`, throttle/debounce, `React.lazy`, `useCallback`/`useMemo`, avoid inline functions in render
## Architecture
**Event system:** `rendererEvents.emit()`, `rendererEvents.subscribe()`, `rendererEvents.unsubscribe()`
**Data flow:** User Action → Redux → Event System → Backend → MQTT → State Update → Re-render
**MVVM:** Model (`backend/src/Model/`), View (components), ViewModel (`app/src/model/`)
---
**Key principle:** Prioritize clarity and maintainability. When in doubt, follow existing code patterns.
+9 -9
View File
@@ -16,10 +16,10 @@
"license": "CC-BY-ND-4.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^7.3.6",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.18.0",
"@mui/lab": "^5.0.0-alpha.177",
"@mui/material": "^5.18.0",
"@mui/styles": "^6.4.8",
"@types/react-transition-group": "^4.4.11",
"ace-builds": "^1.4.11",
@@ -44,9 +44,9 @@
"parse-duration": "^0.1.1",
"path-browserify": "^1.0.1",
"prismjs": "^1.29.0",
"react": "^19.2.3",
"react-ace": "^14.0.1",
"react-dom": "^19.2.3",
"react": "^18.3.1",
"react-ace": "^12.0.0",
"react-dom": "^18.3.1",
"react-redux": "^9.2.0",
"react-resize-detector": "^11.0.1",
"react-split-pane": "^0.1.92",
@@ -68,8 +68,8 @@
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^25.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-redux": "^7.1.34",
"@types/react-resize-detector": "^4.0.3",
"@types/sha1": "^1.1.1",
+6
View File
@@ -147,6 +147,12 @@ export const toggleCertificateSettings = (): Action => ({
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS,
})
export const moveConnection = (connectionId: string, direction: 'up' | 'down'): Action => ({
connectionId,
direction,
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
})
export const deleteConnection = (connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionIds = Object.keys(getState().connectionManager.connections)
const connectionIdLocation = connectionIds.indexOf(connectionId)
+18 -1
View File
@@ -68,6 +68,17 @@ let migrations: Migration[] = [
}
},
},
// Add order field for connection ordering
{
from: 1,
apply: (connection: ConnectionOptions): ConnectionOptions => {
if (connection.order !== undefined) {
return connection
}
// Order will be assigned during migration based on current position
return connection
},
},
]
const connectionMigrator = new ConfigMigrator(migrations)
@@ -80,8 +91,14 @@ function isMigrationNecessary(connections: ConnectionDictionary): boolean {
function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary {
let newConnectionDictionary: ConnectionDictionary = {}
Object.keys(connections).forEach(key => {
const connectionKeys = Object.keys(connections)
connectionKeys.forEach((key, index) => {
let newConnection = connectionMigrator.applyMigrations(connections[key]) as any
// If the migration didn't assign an order, assign one based on current position
if (newConnection.order === undefined) {
newConnection.order = index
}
newConnectionDictionary[newConnection.id] = newConnection
})
-12
View File
@@ -60,18 +60,6 @@ socket.on('connect', () => {
}
})
// Listen for auth-status from server (sent on connection)
socket.on('auth-status', (data: { authDisabled: boolean }) => {
console.log('Auth status received from server:', data)
// Dispatch custom event with auth status
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auth-status', {
detail: { authDisabled: data.authDisabled }
}))
}
})
/**
* Update socket authentication credentials and attempt to reconnect
* @param newUsername New username
+15 -44
View File
@@ -2,7 +2,6 @@ import * as React from 'react'
import { LoginDialog } from './LoginDialog'
import { updateSocketAuth, connectSocket } from '../browserEventBus'
import { isBrowserMode } from '../utils/browserMode'
import { AuthContext } from '../contexts/AuthContext'
interface BrowserAuthWrapperProps {
children: React.ReactNode
@@ -11,48 +10,17 @@ interface BrowserAuthWrapperProps {
export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const [isAuthenticated, setIsAuthenticated] = React.useState(false)
const [loginError, setLoginError] = React.useState<string | undefined>()
const [showLogin, setShowLogin] = React.useState(false)
const [showLogin, setShowLogin] = React.useState(isBrowserMode) // Show login initially in browser mode
const [waitTimeSeconds, setWaitTimeSeconds] = React.useState<number | undefined>()
const [isConnecting, setIsConnecting] = React.useState(false)
const [authCheckComplete, setAuthCheckComplete] = React.useState(false)
const [authDisabled, setAuthDisabled] = React.useState(false)
React.useEffect(() => {
if (!isBrowserMode) {
// Not in browser mode, skip authentication
setIsAuthenticated(true)
setAuthCheckComplete(true)
return
}
// Listen for auth status from socket connection
const handleAuthStatus = (event: CustomEvent) => {
const { authDisabled } = event.detail
setAuthDisabled(authDisabled)
if (authDisabled) {
// Authentication is disabled on server
console.log('Authentication is disabled on server, skipping login')
setIsAuthenticated(true)
setShowLogin(false)
setAuthCheckComplete(true)
} else {
// Authentication is enabled, check if we have credentials
setAuthCheckComplete(true)
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
if (username && password) {
// Credentials exist, connection will authenticate automatically
setIsConnecting(true)
} else {
// No credentials, show login dialog
setShowLogin(true)
}
}
}
// Listen for successful authentication from socket
const handleAuthSuccess = (event: CustomEvent) => {
console.log('Authentication successful')
@@ -98,15 +66,23 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
}
}
// Connect socket to trigger auth-status event
connectSocket()
window.addEventListener('mqtt-auth-status', handleAuthStatus as EventListener)
window.addEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
window.addEventListener('mqtt-auth-error', handleAuthError as EventListener)
// Check if already authenticated
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
if (username && password) {
// Credentials exist, try to connect with them
setIsConnecting(true)
connectSocket()
} else {
// No credentials, show login dialog
setShowLogin(true)
}
return () => {
window.removeEventListener('mqtt-auth-status', handleAuthStatus as EventListener)
window.removeEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
window.removeEventListener('mqtt-auth-error', handleAuthError as EventListener)
}
@@ -133,14 +109,9 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
return <>{props.children}</>
}
// Show nothing while checking auth status to avoid flash
if (!authCheckComplete) {
return null
}
if (!isAuthenticated) {
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} waitTimeSeconds={waitTimeSeconds} />
}
return <AuthContext.Provider value={{ authDisabled }}>{props.children}</AuthContext.Provider>
return <>{props.children}</>
}
-1
View File
@@ -1,4 +1,3 @@
import '../../react-vis-compat' // React 19 compatibility shim for react-vis
import DateFormatter from '../helper/DateFormatter'
import NoData from './NoData'
import NumberFormatter from '../helper/NumberFormatter'
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { ListItem, Typography } from '@mui/material'
import { ListItem, Typography, Box } from '@mui/material'
import { DragIndicator } from '@mui/icons-material'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
@@ -15,6 +16,9 @@ export interface Props {
}
selected: boolean
classes: any
onDragStart: (connectionId: string) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (connectionId: string) => void
}
const ConnectionItem = (props: Props) => {
@@ -25,20 +29,48 @@ const ConnectionItem = (props: Props) => {
}
}, [props.connection, props])
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation()
props.onDragStart(props.connection.id)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
props.onDragOver(e)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
props.onDrop(props.connection.id)
}
const connection = props.connection.host && toMqttConnection(props.connection)
return (
<ListItem
button={true}
selected={props.selected}
style={{ display: 'block' }}
className={props.classes.itemContainer}
onClick={() => props.actions.connectionManager.selectConnection(props.connection.id)}
onDoubleClick={() => {
props.actions.connectionManager.selectConnection(props.connection.id)
connect()
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
<Box
className={props.classes.dragHandle}
draggable
onDragStart={handleDragStart}
>
<DragIndicator fontSize="small" />
</Box>
<Box className={props.classes.textContainer}>
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
</Box>
</ListItem>
)
}
@@ -66,6 +98,25 @@ export const connectionItemStyle = (theme: Theme) => ({
color: theme.palette.text.secondary,
fontSize: '0.7em',
},
itemContainer: {
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
padding: '8px 8px 8px 8px',
},
textContainer: {
flex: 1,
overflow: 'hidden' as 'hidden',
},
dragHandle: {
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
marginRight: '8px',
cursor: 'grab' as 'grab',
color: theme.palette.text.secondary,
'&:active': {
cursor: 'grabbing' as 'grabbing',
},
},
})
export default connect(null, mapDispatchToProps)(withStyles(connectionItemStyle)(ConnectionItem) as any)
@@ -1,6 +1,6 @@
import ConnectionItem from './ConnectionItem'
const ConnectionItemAny = ConnectionItem as any
import React from 'react'
import React, { useState } from 'react'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
@@ -22,13 +22,14 @@ interface Props {
function ProfileList(props: Props) {
const { actions, classes, connections, selected } = props
const [draggedConnectionId, setDraggedConnectionId] = useState<string | null>(null)
const selectConnection = (dir: 'next' | 'previous') => (event: KeyboardEvent) => {
if (!selected) {
return
}
const indexDirection = dir === 'next' ? 1 : -1
const connectionArray = Object.values(connections)
const connectionArray = Object.values(connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const selectedIndex = connectionArray.map(connection => connection.id).indexOf(selected)
const nextConnection = connectionArray[selectedIndex + indexDirection]
if (nextConnection) {
@@ -40,6 +41,39 @@ function ProfileList(props: Props) {
useGlobalKeyEventHandler(KeyCodes.arrow_down, selectConnection('next'))
useGlobalKeyEventHandler(KeyCodes.arrow_up, selectConnection('previous'))
const handleDragStart = (connectionId: string) => {
setDraggedConnectionId(connectionId)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
const handleDrop = (targetConnectionId: string) => {
if (!draggedConnectionId || draggedConnectionId === targetConnectionId) {
setDraggedConnectionId(null)
return
}
const sortedConnections = Object.values(connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const draggedIndex = sortedConnections.findIndex(c => c.id === draggedConnectionId)
const targetIndex = sortedConnections.findIndex(c => c.id === targetConnectionId)
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedConnectionId(null)
return
}
// Swap order values
const draggedConnection = sortedConnections[draggedIndex]
const targetConnection = sortedConnections[targetIndex]
actions.updateConnection(draggedConnection.id, { order: targetConnection.order })
actions.updateConnection(targetConnection.id, { order: draggedConnection.order })
setDraggedConnectionId(null)
}
const createConnectionButton = (
<div style={{ padding: '8px 16px' }}>
<AddButton action={actions.createConnection} />
@@ -50,9 +84,18 @@ function ProfileList(props: Props) {
return (
<List style={{ height: '100%' }} component="nav" subheader={createConnectionButton}>
<div className={classes.list}>
{Object.values(connections).map(connection => (
<ConnectionItemAny connection={connection} key={connection.id} selected={selected === connection.id} />
))}
{Object.values(connections)
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map(connection => (
<ConnectionItemAny
connection={connection}
key={connection.id}
selected={selected === connection.id}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
))}
</div>
</List>
)
+9 -21
View File
@@ -14,7 +14,6 @@ import { connectionActions, globalActions, settingsActions } from '../../actions
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
const styles = (theme: Theme) => ({
title: {
@@ -105,7 +104,15 @@ class TitleBar extends React.PureComponent<Props, {}> {
>
Disconnect <CloudOff className={classes.disconnectIcon} />
</Button>
<LogoutButton classes={classes} onLogout={this.handleLogout} />
{isBrowserMode && (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={this.handleLogout}
>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)}
<ConnectionHealthIndicatorAny withBackground={true} />
</Toolbar>
</AppBar>
@@ -113,25 +120,6 @@ class TitleBar extends React.PureComponent<Props, {}> {
}
}
// Separate component to use hooks
function LogoutButton({ classes, onLogout }: { classes: any; onLogout: () => void }) {
const { authDisabled } = useAuth()
if (!isBrowserMode || authDisabled) {
return null
}
return (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={onLogout}
>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
@@ -41,29 +41,31 @@ function ChartPreview(props: Props) {
const addChartToPanelButton = hasEnoughDataToDisplayDiagrams ? (
<Tooltip title="Add to chart panel">
<span
<ShowChart
ref={chartIconRef}
className={props.classes.icon}
onMouseEnter={mouseOver}
onMouseLeave={mouseOut}
onClick={onClick}
style={{ cursor: 'pointer', display: 'inline-flex' }}
>
<ShowChart className={props.classes.icon} />
</span>
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
) : (
<Tooltip title="Add to chart panel, not enough data for preview">
<span onClick={onClick} style={{ cursor: 'pointer', display: 'inline-flex' }}>
<ShowChart className={props.classes.icon} style={{ color: '#aaa' }} />
</span>
<ShowChart
onClick={onClick}
className={props.classes.icon}
style={{ color: '#aaa' }}
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
)
return (
<div style={{ display: 'inline' }}>
<span data-test-type="ShowChart" data-test={props.literal.path} style={{ display: 'inline-block' }}>
{addChartToPanelButton}
</span>
<span>
{addChartToPanelButton}
<Popper open={open} anchorEl={chartIconRef.current} placement="left-end">
<Fade in={open} timeout={300}>
<Paper style={{ width: '300px' }}>
@@ -75,7 +77,7 @@ function ChartPreview(props: Props) {
</Paper>
</Fade>
</Popper>
</div>
</span>
)
}
@@ -1,8 +1,8 @@
import React, { useCallback } from 'react'
import Code from '@mui/icons-material/Code'
import Reorder from '@mui/icons-material/Reorder'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import ToggleButton from '@mui/lab/ToggleButton'
import ToggleButtonGroup from '@mui/lab/ToggleButtonGroup'
import { settingsActions } from '../../../actions'
import { Tooltip } from '@mui/material'
import { withStyles } from '@mui/styles'
-13
View File
@@ -1,13 +0,0 @@
import * as React from 'react'
interface AuthContextType {
authDisabled: boolean
}
export const AuthContext = React.createContext<AuthContextType>({
authDisabled: false,
})
export function useAuth() {
return React.useContext(AuthContext)
}
+4
View File
@@ -27,6 +27,7 @@ export interface ConnectionOptions {
clientKey?: CertificateParameters
clientId?: string
subscriptions: Array<Subscription>
order?: number
}
export function toMqttConnection(options: ConnectionOptions): MqttOptions | undefined {
@@ -71,6 +72,7 @@ export function createEmptyConnection(): ConnectionOptions {
host: '',
port: 1883,
protocol: 'mqtt',
order: Date.now(),
}
}
@@ -82,12 +84,14 @@ export function makeDefaultConnections() {
id: 'mqtt.eclipseprojects.io',
name: 'mqtt.eclipseprojects.io',
host: 'mqtt.eclipseprojects.io',
order: 0,
},
'test.mosquitto.org': {
...createEmptyConnection(),
id: 'test.mosquitto.org',
name: 'test.mosquitto.org',
host: 'test.mosquitto.org',
order: 1,
},
}
}
-28
View File
@@ -1,28 +0,0 @@
/**
* React 19 compatibility shim for react-vis
*
* react-vis uses React internals that were removed in React 19.
* This shim adds back the missing internals to maintain compatibility.
*/
import * as React from 'react'
// Add missing React internals that react-vis expects
if (typeof React !== 'undefined') {
const internals = (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
if (internals) {
// ReactCurrentOwner was removed in React 19 but react-vis expects it
if (!internals.ReactCurrentOwner) {
internals.ReactCurrentOwner = {
current: null,
}
}
// ReactCurrentDispatcher compatibility
if (!internals.ReactCurrentDispatcher) {
internals.ReactCurrentDispatcher = {
current: null,
}
}
}
}
+46
View File
@@ -26,6 +26,7 @@ export type Action =
| ToggleCertificateSettings
| DeleteSubscription
| AddSubscription
| MoveConnection
export enum ActionTypes {
CONNECTION_MANAGER_SET_CONNECTIONS = 'CONNECTION_MANAGER_SET_CONNECTIONS',
@@ -37,6 +38,7 @@ export enum ActionTypes {
CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS = 'CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS',
CONNECTION_MANAGER_ADD_SUBSCRIPTION = 'CONNECTION_MANAGER_ADD_SUBSCRIPTION',
CONNECTION_MANAGER_DELETE_SUBSCRIPTION = 'CONNECTION_MANAGER_DELETE_SUBSCRIPTION',
CONNECTION_MANAGER_MOVE_CONNECTION = 'CONNECTION_MANAGER_MOVE_CONNECTION',
}
export interface SetConnections {
@@ -85,6 +87,12 @@ export interface ToggleCertificateSettings {
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS
}
export interface MoveConnection {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION
connectionId: string
direction: 'up' | 'down'
}
export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_SET_CONNECTIONS: setConnections,
CONNECTION_MANAGER_SELECT_CONNECTION: selectConnection,
@@ -95,6 +103,7 @@ export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS: toggleCertificateSettings,
CONNECTION_MANAGER_DELETE_SUBSCRIPTION: deleteSubscription,
CONNECTION_MANAGER_ADD_SUBSCRIPTION: addSubscription,
CONNECTION_MANAGER_MOVE_CONNECTION: moveConnection,
})
function setConnections(state: ConnectionManagerState, action: SetConnections): ConnectionManagerState {
@@ -222,3 +231,40 @@ function updateConnection(state: ConnectionManagerState, action: UpdateConnectio
},
}
}
function moveConnection(state: ConnectionManagerState, action: MoveConnection): ConnectionManagerState {
const connections = Object.values(state.connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const currentIndex = connections.findIndex(c => c.id === action.connectionId)
if (currentIndex === -1) {
return state
}
const targetIndex = action.direction === 'up' ? currentIndex - 1 : currentIndex + 1
// Can't move beyond bounds
if (targetIndex < 0 || targetIndex >= connections.length) {
return state
}
// Swap order values
const currentConnection = connections[currentIndex]
const targetConnection = connections[targetIndex]
const currentOrder = currentConnection.order || 0
const targetOrder = targetConnection.order || 0
return {
...state,
connections: {
...state.connections,
[currentConnection.id]: {
...currentConnection,
order: targetOrder,
},
[targetConnection.id]: {
...targetConnection,
order: currentOrder,
},
},
}
}
@@ -0,0 +1,139 @@
import 'mocha'
import { expect } from 'chai'
import { connectionManagerReducer, ConnectionManagerState, ActionTypes } from '../ConnectionManager'
import { createEmptyConnection } from '../../model/ConnectionOptions'
describe('ConnectionManager - moveConnection', () => {
it('should move connection up', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const connection3 = { ...createEmptyConnection(), id: 'conn3', name: 'Connection 3', order: 2 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
conn3: connection3,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(1)
expect(newState.connections.conn2.order).to.equal(0)
expect(newState.connections.conn3.order).to.equal(2)
})
it('should move connection down', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const connection3 = { ...createEmptyConnection(), id: 'conn3', name: 'Connection 3', order: 2 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
conn3: connection3,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'down' as 'down',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(2)
expect(newState.connections.conn3.order).to.equal(1)
})
it('should not move connection up when already at top', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn1',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(1)
})
it('should not move connection down when already at bottom', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'down' as 'down',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(1)
})
it('should handle non-existent connection', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'nonexistent',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
})
})
+1 -1
View File
@@ -10,7 +10,7 @@
"sourceMap": true,
"module": "esnext",
"target": "ES2020",
"jsx": "react-jsx",
"jsx": "react",
"paths": {
"react": ["./node_modules/@types/react"]
},
+153 -114
View File
@@ -52,7 +52,7 @@
dependencies:
"@babel/types" "^7.28.5"
"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
@@ -114,7 +114,7 @@
source-map "^0.5.7"
stylis "4.2.0"
"@emotion/cache@^11.14.0":
"@emotion/cache@^11.13.5", "@emotion/cache@^11.14.0":
version "11.14.0"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.14.0.tgz#ee44b26986eeb93c8be82bb92f1f7a9b21b2ed76"
integrity sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==
@@ -172,7 +172,7 @@
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.4.0.tgz#c9299c34d248bc26e82563735f78953d2efca83c"
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
"@emotion/styled@^11.14.1":
"@emotion/styled@^11.14.0":
version "11.14.1"
resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.14.1.tgz#8c34bed2948e83e1980370305614c20955aacd1c"
integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==
@@ -204,6 +204,33 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
"@floating-ui/core@^1.7.3":
version "1.7.3"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7"
integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.7.4":
version "1.7.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77"
integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
dependencies:
"@floating-ui/core" "^1.7.3"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/react-dom@^2.0.8":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.6.tgz#189f681043c1400561f62972f461b93f01bf2231"
integrity sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==
dependencies:
"@floating-ui/dom" "^1.7.4"
"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@jridgewell/gen-mapping@^0.3.0":
version "0.3.5"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36"
@@ -315,48 +342,71 @@
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==
"@mui/core-downloads-tracker@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz#e7e3a4dc161a377be8224aa988410e89571ab40a"
integrity sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==
"@mui/icons-material@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.3.6.tgz#c0092afd04a661603d9751c851e0099a27c1d556"
integrity sha512-0FfkXEj22ysIq5pa41A2NbcAhJSvmcZQ/vcTIbjDsd6hlslG82k5BEBqqS0ZJprxwIL3B45qpJ+bPHwJPlF7uQ==
"@mui/base@5.0.0-beta.40-1":
version "5.0.0-beta.40-1"
resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.40-1.tgz#6da6229e5e675e811f319149f6e29d7a77522851"
integrity sha512-agKXuNNy0bHUmeU7pNmoZwNFr7Hiyhojkb9+2PVyDG5+6RafYuyMgbrav8CndsB7KUc/U51JAw9vKNDLYBzaUA==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/lab@^7.0.1-beta.20":
version "7.0.1-beta.20"
resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-7.0.1-beta.20.tgz#e63282cf686c46c44b36066c99dc7e3cf3acdf86"
integrity sha512-xZW+gLO0htUjL02lZRhrziyOuz/azdwqgyiyjKvn52W2wbbcXtFhDVp3ns7YYiQAF9I+Sgu1g1a2HZutOlqeWw==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/system" "^7.3.6"
"@mui/types" "^7.4.9"
"@mui/utils" "^7.3.6"
clsx "^2.1.1"
"@babel/runtime" "^7.23.9"
"@floating-ui/react-dom" "^2.0.8"
"@mui/types" "~7.2.15"
"@mui/utils" "^5.17.1"
"@popperjs/core" "^2.11.8"
clsx "^2.1.0"
prop-types "^15.8.1"
"@mui/material@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.6.tgz#6bd4705ca97d80fd5ae1b6b2b7c56ba0cfab0d6a"
integrity sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==
"@mui/core-downloads-tracker@^5.18.0":
version "5.18.0"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz#85019a8704b0f63305fc5600635ee663810f2b66"
integrity sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==
"@mui/icons-material@^5.18.0":
version "5.18.0"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.18.0.tgz#97d87f1b7bee5fa7b9ba844518631de3112c1e57"
integrity sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/core-downloads-tracker" "^7.3.6"
"@mui/system" "^7.3.6"
"@mui/types" "^7.4.9"
"@mui/utils" "^7.3.6"
"@babel/runtime" "^7.23.9"
"@mui/lab@^5.0.0-alpha.177":
version "5.0.0-alpha.177"
resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-5.0.0-alpha.177.tgz#a02f43b36d7e204166706f2c27640f378e3d7c88"
integrity sha512-bdCxxtNjlWAgN9rtrwlmFydJ1qxA3IIbb6OlomGFsIXw0zGoHomLyjvh72q/R3yUAC0kvSef18cHY1UalLylyQ==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/base" "5.0.0-beta.40-1"
"@mui/system" "^5.18.0"
"@mui/types" "~7.2.15"
"@mui/utils" "^5.17.1"
clsx "^2.1.0"
prop-types "^15.8.1"
"@mui/material@^5.18.0":
version "5.18.0"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.18.0.tgz#71e72d52338252edc6f8d9461e04fdf0d61905cd"
integrity sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/core-downloads-tracker" "^5.18.0"
"@mui/system" "^5.18.0"
"@mui/types" "~7.2.15"
"@mui/utils" "^5.17.1"
"@popperjs/core" "^2.11.8"
"@types/react-transition-group" "^4.4.12"
clsx "^2.1.1"
"@types/react-transition-group" "^4.4.10"
clsx "^2.1.0"
csstype "^3.1.3"
prop-types "^15.8.1"
react-is "^19.2.0"
react-is "^19.0.0"
react-transition-group "^4.4.5"
"@mui/private-theming@^5.17.1":
version "5.17.1"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.17.1.tgz#b4b6fbece27830754ef78186e3f1307dca42f295"
integrity sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/utils" "^5.17.1"
prop-types "^15.8.1"
"@mui/private-theming@^6.4.8":
version "6.4.9"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-6.4.9.tgz#0c1d65a638a1740aad0eb715d79e76471abe8175"
@@ -366,24 +416,14 @@
"@mui/utils" "^6.4.9"
prop-types "^15.8.1"
"@mui/private-theming@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-7.3.6.tgz#1ca65a08e8f7f538d9a10ba974f1f4db5231a969"
integrity sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==
"@mui/styled-engine@^5.18.0":
version "5.18.0"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.18.0.tgz#914cca1385bb33ce0cde31721f529c8bd7fa301c"
integrity sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/utils" "^7.3.6"
prop-types "^15.8.1"
"@mui/styled-engine@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-7.3.6.tgz#dde8e6ae32c9b5b400dcd37afd9514a5344f7d91"
integrity sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==
dependencies:
"@babel/runtime" "^7.28.4"
"@emotion/cache" "^11.14.0"
"@babel/runtime" "^7.23.9"
"@emotion/cache" "^11.13.5"
"@emotion/serialize" "^1.3.3"
"@emotion/sheet" "^1.4.0"
csstype "^3.1.3"
prop-types "^15.8.1"
@@ -410,32 +450,37 @@
jss-plugin-vendor-prefixer "^10.10.0"
prop-types "^15.8.1"
"@mui/system@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-7.3.6.tgz#460f82fc6fe1b79b8c04dc97694f6b162ffc3d25"
integrity sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==
"@mui/system@^5.18.0":
version "5.18.0"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.18.0.tgz#e55331203a40584b26c5a855a07949ac8973bfb6"
integrity sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/private-theming" "^7.3.6"
"@mui/styled-engine" "^7.3.6"
"@mui/types" "^7.4.9"
"@mui/utils" "^7.3.6"
clsx "^2.1.1"
"@babel/runtime" "^7.23.9"
"@mui/private-theming" "^5.17.1"
"@mui/styled-engine" "^5.18.0"
"@mui/types" "~7.2.15"
"@mui/utils" "^5.17.1"
clsx "^2.1.0"
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/types@^7.4.9":
version "7.4.9"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.9.tgz#99accc87920b4c8c4ce33c5076a58f7f81b528fa"
integrity sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/types@~7.2.24":
"@mui/types@~7.2.15", "@mui/types@~7.2.24":
version "7.2.24"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.24.tgz#5eff63129d9c29d80bbf2d2e561bd0690314dec2"
integrity sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==
"@mui/utils@^5.17.1":
version "5.17.1"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.17.1.tgz#72ba4ffa79f7bdf69d67458139390f18484b6e6b"
integrity sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/types" "~7.2.15"
"@types/prop-types" "^15.7.12"
clsx "^2.1.1"
prop-types "^15.8.1"
react-is "^19.0.0"
"@mui/utils@^6.4.8", "@mui/utils@^6.4.9":
version "6.4.9"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.4.9.tgz#b0df01daa254c7c32a1a30b30a5179e19ef071a7"
@@ -448,18 +493,6 @@
prop-types "^15.8.1"
react-is "^19.0.0"
"@mui/utils@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.6.tgz#508fbe864832f99b215d134eb89e1198cdc66b34"
integrity sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==
dependencies:
"@babel/runtime" "^7.28.4"
"@mui/types" "^7.4.9"
"@types/prop-types" "^15.7.15"
clsx "^2.1.1"
prop-types "^15.8.1"
react-is "^19.2.0"
"@polka/url@^1.0.0-next.24":
version "1.0.0-next.25"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817"
@@ -874,7 +907,7 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
"@types/prop-types@^15.7.14", "@types/prop-types@^15.7.15":
"@types/prop-types@^15.7.12", "@types/prop-types@^15.7.14":
version "15.7.15"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
@@ -889,10 +922,10 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react-dom@^19.2.3":
version "19.2.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
"@types/react-dom@^18.3.5":
version "18.3.7"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f"
integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==
"@types/react-redux@^7.1.34":
version "7.1.34"
@@ -911,7 +944,7 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.11", "@types/react-transition-group@^4.4.12":
"@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.11":
version "4.4.12"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
@@ -925,11 +958,12 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@^19.2.7":
version "19.2.7"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f"
integrity sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==
"@types/react@^18.3.18":
version "18.3.27"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.27.tgz#74a3b590ea183983dc65a474dc17553ae1415c34"
integrity sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==
dependencies:
"@types/prop-types" "*"
csstype "^3.2.2"
"@types/retry@0.12.2":
@@ -1176,7 +1210,7 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
ace-builds@^1.36.3:
ace-builds@^1.32.8:
version "1.43.5"
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.43.5.tgz#2099eee8caade0cd1ee4ce3e0d2c6fb2cd7e8619"
integrity sha512-iH5FLBKdB7SVn9GR37UgA/tpQS8OTWIxWAuq3Ofaw+Qbc69FfPXsXd9jeW7KRG2xKpKMqBDnu0tHBrCWY5QI7A==
@@ -1549,7 +1583,7 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
clsx@^2.1.1:
clsx@^2.1.0, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@@ -3341,7 +3375,7 @@ log-symbols@^4.1.0:
chalk "^4.1.0"
is-unicode-supported "^0.1.0"
loose-envify@^1.4.0:
loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -3943,30 +3977,31 @@ raw-body@~2.5.3:
iconv-lite "~0.4.24"
unpipe "~1.0.0"
react-ace@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-14.0.1.tgz#dba5761cee92a1e522be7e7dab7584df7e1aa1c7"
integrity sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==
react-ace@^12.0.0:
version "12.0.0"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-12.0.0.tgz#d40afc7382092109eead7227d9426f55dcc2209d"
integrity sha512-PstU6CSMfYIJknb4su2Fa0WgLXzq2ufQgR6fjcSWuGT1hGTHkBzuKw+SncV8PuLCdSJBJc1VehPhyeXlWByG/g==
dependencies:
ace-builds "^1.36.3"
ace-builds "^1.32.8"
diff-match-patch "^1.0.5"
lodash.get "^4.4.2"
lodash.isequal "^4.5.0"
prop-types "^15.8.1"
react-dom@^19.2.3:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
react-dom@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
dependencies:
scheduler "^0.27.0"
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^19.0.0, react-is@^19.2.0:
react-is@^19.0.0:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
@@ -4049,10 +4084,12 @@ react-vis@^1.12.1:
prop-types "^15.5.8"
react-motion "^0.5.2"
react@^19.2.3:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
react@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
dependencies:
loose-envify "^1.1.0"
readable-stream@^2.0.1:
version "2.3.8"
@@ -4223,10 +4260,12 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
scheduler@^0.23.2:
version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
schema-utils@^3.0.0:
version "3.3.0"
-24
View File
@@ -1,24 +0,0 @@
version: '3.8'
services:
mqtt-explorer:
image: ghcr.io/thomasnordquist/mqtt-explorer:latest
ports:
- "3000:3000"
environment:
- MQTT_EXPLORER_USERNAME=admin
- MQTT_EXPLORER_PASSWORD=changeme
# Uncomment to disable authentication (use only behind a secure proxy!)
# - MQTT_EXPLORER_SKIP_AUTH=true
volumes:
- mqtt-explorer-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
mqtt-explorer-data:
+7 -14
View File
@@ -11,21 +11,17 @@
"start": "electron .",
"start:server": "npx tsc && node dist/src/server.js",
"test": "yarn test:app && yarn test:backend",
"test:all": "yarn test:app && yarn test:backend && yarn test:demo-video",
"test:app": "(cd app && yarn test)",
"test:backend": "(cd backend && yarn test)",
"test:electron": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:browser": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:demo-video": "npx tsc && node dist/src/spec/demoVideo.js",
"test:app": "cd app && yarn test",
"test:backend": "cd backend && yarn test",
"test:ui": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:ui:vnc": "tsc && ./scripts/uiTestsWithVnc.sh",
"test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js",
"install": "(cd app && yarn)",
"install": "cd app && yarn && cd ..",
"dev": "npm-run-all --parallel dev:*",
"dev:app": "(cd app && npm run dev)",
"dev:app": "cd app && npm run dev",
"dev:electron": "tsc && electron . --development",
"dev:server": "npm-run-all --parallel dev:server:*",
"dev:server:app": "(cd app && npx webpack-dev-server --config webpack.browser.config.mjs --mode development --progress)",
"dev:server:app": "cd app && npx webpack-dev-server --config webpack.browser.config.mjs --mode development --progress",
"dev:server:backend": "tsc && node dist/src/server.js",
"lint": "npm-run-all --parallel lint:prettier lint:tslint lint:spellcheck",
"lint:fix": "npm-run-all lint:tslint:fix lint:prettier:fix",
@@ -34,8 +30,8 @@
"lint:tslint": "tslint -p ./",
"lint:tslint:fix": "tslint -p ./ --fix",
"lint:spellcheck": "cspell -e ./build -e \"node_modules\" \"**/*.ts{x,}\"",
"build": "tsc && (cd app && yarn run build)",
"build:server": "npx tsc && (cd app && npx webpack --config webpack.browser.config.mjs --mode production)",
"build": "tsc && cd app && yarn run build && cd ..",
"build:server": "npx tsc && cd app && npx webpack --config webpack.browser.config.mjs --mode production && cd ..",
"prepare-release": "tsx scripts/prepare-release.ts",
"package": "tsx package.ts",
"ui-test": "./scripts/uiTests.sh",
@@ -46,9 +42,6 @@
"type": "git",
"url": "https://github.com/thomasnordquist/MQTT-Explorer.git"
},
"resolutions": {
"@electron/node-gyp": "10.2.0-electron.1"
},
"build": {
"appId": "mqtt-explorer",
"productName": "MQTT Explorer",
-16
View File
@@ -12,30 +12,14 @@ export interface Credentials {
export class AuthManager {
private credentialsPath: string
private credentials: Credentials | undefined
private skipAuth: boolean
constructor(credentialsPath: string) {
this.credentialsPath = credentialsPath
this.skipAuth = process.env.MQTT_EXPLORER_SKIP_AUTH === 'true'
}
public isAuthDisabled(): boolean {
return this.skipAuth
}
public async initialize(): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production'
// Check if authentication is disabled
if (this.skipAuth) {
console.log('='.repeat(60))
console.log('WARNING: Authentication is DISABLED')
console.log('MQTT_EXPLORER_SKIP_AUTH=true')
console.log('This should only be used behind a secure authentication proxy!')
console.log('='.repeat(60))
return
}
// Try to get credentials from environment variables
const envUsername = process.env.MQTT_EXPLORER_USERNAME
const envPassword = process.env.MQTT_EXPLORER_PASSWORD
-21
View File
@@ -157,16 +157,6 @@ async function startServer() {
// Authentication middleware for Socket.io
io.use(async (socket, next) => {
// Skip authentication if disabled
if (authManager.isAuthDisabled()) {
if (!isProduction) {
console.log('Client connected without authentication (auth disabled)')
}
// Mark socket as auth-disabled for later identification
;(socket as any).authDisabled = true
return next()
}
const { username, password } = socket.handshake.auth
const clientIp = socket.handshake.address
@@ -215,17 +205,6 @@ async function startServer() {
next()
})
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
})
// Initialize backend event bus with Socket.io
const backendEvents = new SocketIOServerEventBus(io)
const backendRpc = new Rpc(backendEvents)
+1 -9
View File
@@ -16,16 +16,9 @@ export async function createTestMock(): Promise<mqtt.MqttClient> {
return mqttClient
}
// Use MQTT_BROKER_HOST from environment, default to localhost
const brokerHost = process.env.MQTT_BROKER_HOST || '127.0.0.1'
const brokerPort = process.env.MQTT_BROKER_PORT || '1883'
const brokerUrl = `mqtt://${brokerHost}:${brokerPort}`
console.log(`Connecting to MQTT broker at ${brokerUrl}`)
return new Promise((resolve, reject) => {
console.log('Connecting to MQTT broker at mqtt://127.0.0.1:1883...')
const client = mqtt.connect(brokerUrl, {
const client = mqtt.connect('mqtt://127.0.0.1:1883', {
username: '',
password: '',
connectTimeout: 10000,
@@ -35,7 +28,6 @@ export async function createTestMock(): Promise<mqtt.MqttClient> {
client.once('connect', () => {
console.log('Successfully connected to MQTT broker')
mqttClient = client
console.log(`Connected to MQTT broker at ${brokerUrl}`)
resolve(client)
})
+10 -6
View File
@@ -43,12 +43,16 @@ export async function showNumericPlot(browser: Page) {
}
async function valuePreviewGuttersShowChartIcon(name: string, browser: Page) {
const locator = browser
.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`)
.first()
await locator.waitFor({ state: 'visible', timeout: 30000 })
return locator
for (let retries = 0; retries < 2; retries += 1) {
try {
return await browser
.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`)
.first()
} catch {
// ignore
}
}
return browser.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`).first()
}
async function chartSettings(name: string, browser: Page) {
+3 -5
View File
@@ -32,7 +32,7 @@ import type { MqttClient } from 'mqtt'
* - Handle MQTT asynchronous operations properly
*
* Prerequisites:
* - MQTT broker running (default: localhost:1883, configurable via MQTT_BROKER_HOST and MQTT_BROKER_PORT)
* - MQTT broker running on localhost:1883
* - Application built with `yarn build`
*/
// tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression
@@ -125,10 +125,8 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
page = await electronApp.firstWindow({ timeout: 30000 })
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
// Use MQTT_BROKER_HOST from environment, default to localhost
const brokerHost = process.env.MQTT_BROKER_HOST || '127.0.0.1'
console.log(`Connecting to MQTT broker at ${brokerHost}...`)
await connectTo(brokerHost, page)
console.log('Connecting to MQTT broker...')
await connectTo('127.0.0.1', page)
await sleep(3000) // Give time for all topics to load
// Start Sparkplug client after connection
+2 -3
View File
@@ -667,10 +667,9 @@
optionalDependencies:
global-agent "^3.0.0"
"@electron/node-gyp@10.2.0-electron.1", "@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2":
"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2":
version "10.2.0-electron.1"
resolved "https://registry.yarnpkg.com/@electron/node-gyp/-/node-gyp-10.2.0-electron.1.tgz#ca5f125dcd0ffb275797c0c418c0d64005e0f815"
integrity sha512-YdpRE6qSNYyf7gBv1LBDc8OAs8f/mZthzM1k4pFzodNq8dBGf64MWC5Bq8VVlgdafjQXLpINHvtRAUC9uinoqw==
resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2"
dependencies:
env-paths "^2.2.0"
exponential-backoff "^3.1.1"