mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-11 09:03:33 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be83c3f2cc | ||
|
|
1a8a9224b4 | ||
|
|
651246ff83 | ||
|
|
73b8e6ff9f | ||
|
|
eb605a884c | ||
|
|
9868ac67fc | ||
|
|
229414de28 | ||
|
|
6c041cba02 | ||
|
|
a7136bd572 | ||
|
|
a5629b8c77 | ||
|
|
da122e06f1 | ||
|
|
e0a79f61af | ||
|
|
26ed0aadd2 | ||
|
|
578bb510f9 | ||
|
|
e725b1d012 | ||
|
|
c55c3a8245 |
@@ -2,7 +2,7 @@
|
||||
"name": "MQTT Explorer Development",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"workspaceFolder": "/workspace/MQTT-Explorer",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -15,7 +15,6 @@
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
@@ -24,7 +23,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"forwardPorts": [3000, 8080, 1883],
|
||||
"forwardPorts": [3000, 8080, 1883, 5900, 6080],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "MQTT Explorer Server",
|
||||
@@ -37,10 +36,29 @@
|
||||
"1883": {
|
||||
"label": "MQTT Broker",
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"5900": {
|
||||
"label": "VNC Server",
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"6080": {
|
||||
"label": "noVNC Web Client",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "yarn install",
|
||||
|
||||
"postStartCommand": "sudo apt-get update && sudo apt-get install -y mosquitto xvfb x11vnc ffmpeg tmux python3 python3-pip && sudo pip3 install --break-system-packages websockify",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"installZsh": true,
|
||||
"installOhMyZsh": true,
|
||||
"upgradePackages": true
|
||||
}
|
||||
},
|
||||
|
||||
"remoteUser": "node"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:20
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:24
|
||||
volumes:
|
||||
- ../..:/workspace:cached
|
||||
- ..:/workspaces/MQTT-Explorer:cached
|
||||
command: sleep infinity
|
||||
network_mode: service:mosquitto
|
||||
environment:
|
||||
@@ -14,8 +14,10 @@ services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "3000:3000"
|
||||
- "8080:8080"
|
||||
- '1883:1883'
|
||||
- '3000:3000'
|
||||
- '8080:8080'
|
||||
- '5900:5900'
|
||||
- '6080:6080'
|
||||
volumes:
|
||||
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
|
||||
+177
-353
@@ -1,383 +1,207 @@
|
||||
# GitHub Copilot Agent Instructions for MQTT Explorer
|
||||
|
||||
## Overview
|
||||
## Debugging Browser Mode
|
||||
|
||||
MQTT Explorer is an Electron-based desktop application for exploring MQTT brokers. It provides a comprehensive UI for connecting to MQTT brokers, browsing topics, and analyzing message flows.
|
||||
### Prerequisites
|
||||
- Node.js 24 or higher
|
||||
- Yarn package manager
|
||||
- Running Mosquitto MQTT broker (for testing)
|
||||
|
||||
## Technology Stack
|
||||
### Development Mode (with Hot Reload)
|
||||
|
||||
- **Frontend**: React 16.x with Material-UI
|
||||
- **Backend**: Node.js with TypeScript
|
||||
- **Desktop Framework**: Electron 29.x
|
||||
- **MQTT Client**: [mqttjs](https://github.com/mqttjs/MQTT.js) v4.x
|
||||
- **State Management**: Redux with redux-thunk
|
||||
- **Build Tools**: webpack, TypeScript compiler
|
||||
- **Testing**: Mocha + Chai for unit tests, Playwright for MCP introspection tests
|
||||
1. **Set credentials (required):**
|
||||
```bash
|
||||
export MQTT_EXPLORER_USERNAME=admin
|
||||
export MQTT_EXPLORER_PASSWORD=your_password
|
||||
```
|
||||
|
||||
## Project Setup
|
||||
2. **Start development servers:**
|
||||
```bash
|
||||
yarn dev:server
|
||||
```
|
||||
This runs two servers in parallel:
|
||||
- Backend server on http://localhost:3000 (serves API, WebSocket, authentication)
|
||||
- Webpack dev server on http://localhost:8080 (serves frontend with hot reload)
|
||||
|
||||
### Building and Running
|
||||
3. **Access the application:**
|
||||
- Navigate to http://localhost:8080 (NOT :3000)
|
||||
- Webpack dev server proxies API/WebSocket requests to backend on port 3000
|
||||
- Hot reload enabled - changes to React components update automatically
|
||||
|
||||
### Production Mode (Production Build)
|
||||
|
||||
1. **Build the browser version:**
|
||||
```bash
|
||||
yarn build:server
|
||||
```
|
||||
This compiles TypeScript and builds the optimized webpack bundle
|
||||
|
||||
2. **Start the server:**
|
||||
```bash
|
||||
# Set credentials (required) - these are for the browser login page
|
||||
export MQTT_EXPLORER_USERNAME=admin
|
||||
export MQTT_EXPLORER_PASSWORD=your_password
|
||||
|
||||
# Start server
|
||||
yarn start:server
|
||||
# OR: node dist/src/server.js
|
||||
```
|
||||
Server will run on http://localhost:3000 (serves both frontend and backend)
|
||||
|
||||
3. **Login to the application:**
|
||||
- Navigate to http://localhost:3000
|
||||
- Enter the username and password you set in the environment variables
|
||||
- Click "LOGIN" button
|
||||
- After successful login, the main application will load
|
||||
- The MQTT Connection modal will appear where you can configure broker connections
|
||||
|
||||
### Debugging with Browser DevTools
|
||||
|
||||
1. **Open browser DevTools:**
|
||||
- Navigate to http://localhost:3000
|
||||
- Press F12 or right-click → Inspect
|
||||
|
||||
2. **Check Console tab:**
|
||||
- Look for JavaScript errors
|
||||
- CSP (Content Security Policy) errors indicate security header issues
|
||||
- Network errors indicate API/WebSocket connection issues
|
||||
|
||||
3. **Check Network tab:**
|
||||
- Verify static assets load correctly (JS bundles, CSS)
|
||||
- Check WebSocket connection status
|
||||
- Monitor API calls for authentication issues
|
||||
|
||||
4. **Common Issues:**
|
||||
|
||||
**Blank page / CSP errors:**
|
||||
- Symptom: Console shows `EvalError: ... violates Content Security Policy`
|
||||
- Cause: webpack runtime requires `unsafe-eval` for code splitting
|
||||
- Fix: Add `'unsafe-eval'` to `scriptSrc` in `src/server.ts` helmet config
|
||||
|
||||
**Authentication loop:**
|
||||
- Symptom: Login dialog keeps reappearing
|
||||
- Cause: WebSocket authentication failing
|
||||
- Debug: Check browser Network tab → WS → Messages
|
||||
- Check: Server logs for authentication errors
|
||||
|
||||
**Theme errors:**
|
||||
- Symptom: App loads but styling is broken
|
||||
- Cause: Material-UI theme not loading correctly
|
||||
- Check: Console for theme-related errors
|
||||
- Verify: Both ThemeProvider and LegacyThemeProvider in `app/src/index.tsx`
|
||||
|
||||
**Expected console warnings (non-fatal):**
|
||||
- React 18 type warnings with Material-UI v5 components (dozens of "Failed prop type" warnings)
|
||||
- `TypeError: Cannot read properties of undefined (reading 'on')` from IpcRendererEventBus - this is expected in browser mode as there's no Electron IPC
|
||||
- MUI locale warnings for `en-US` - expected, app uses available locales
|
||||
- `componentWillReceiveProps` deprecation warnings - from legacy TreeComponent
|
||||
- ACE editor autocomplete warnings - expected, features not imported
|
||||
- CSP worker violation for ACE editor - known issue, editor still functions
|
||||
|
||||
These warnings don't prevent the application from functioning correctly.
|
||||
|
||||
### Using Playwright for Automated Testing
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
yarn install
|
||||
|
||||
# Build the project
|
||||
yarn build
|
||||
|
||||
# Set password for browser testing
|
||||
# Start server in background
|
||||
export MQTT_EXPLORER_USERNAME=admin
|
||||
export MQTT_EXPLORER_PASSWORD=secretpassword
|
||||
export MQTT_EXPLORER_PASSWORD=test123
|
||||
node dist/src/server.js &
|
||||
|
||||
# Start the application
|
||||
yarn start
|
||||
|
||||
# Start in development mode
|
||||
yarn dev
|
||||
# Use Playwright browser tool (in Copilot agent context)
|
||||
playwright-browser_navigate http://localhost:3000
|
||||
playwright-browser_take_screenshot --filename debug.png
|
||||
playwright-browser_console_messages # Check for errors
|
||||
```
|
||||
|
||||
### Running with MCP Introspection (for testing)
|
||||
### Expected UI Flow
|
||||
|
||||
```bash
|
||||
# Build first
|
||||
yarn build
|
||||
|
||||
# Start with MCP introspection enabled
|
||||
electron . --enable-mcp-introspection
|
||||
|
||||
# Or with custom port
|
||||
electron . --enable-mcp-introspection --remote-debugging-port=9223
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Requirements for All Tests
|
||||
|
||||
1. **Tests MUST be deterministic** - They should produce the same results every time they run
|
||||
2. **Tests MUST be independent** - Each test should be able to run in isolation without depending on other tests
|
||||
3. **Include screenshots** - Visual verification is required for UI changes
|
||||
4. **Handle asynchronous operations properly** - This is an MQTT message queue tool
|
||||
|
||||
### Best Practices for UI Tests
|
||||
|
||||
#### 1. Use Given-When-Then Pattern
|
||||
Structure tests with clear Given-When-Then comments to make them readable:
|
||||
|
||||
```typescript
|
||||
it('Given a JSON message sent to topic foo/bar/baz, the tree should display nested topics', async function () {
|
||||
// Given: Mock MQTT publishes JSON to foo/bar/baz
|
||||
// When: We wait for the topic to appear in the tree
|
||||
// Then: Topic hierarchy should be visible (foo -> bar -> baz)
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Wait for Elements, Don't Use Fixed Delays
|
||||
Prefer `waitFor` over `sleep` whenever possible:
|
||||
|
||||
```typescript
|
||||
// ✓ Good: Wait for specific element
|
||||
const topic = await page.locator('span[data-test-topic="kitchen"]')
|
||||
await topic.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
// ✗ Bad: Fixed delay without verification
|
||||
await sleep(5000)
|
||||
```
|
||||
|
||||
#### 3. Use Meaningful Assertions
|
||||
Every test should have explicit assertions that verify the expected state:
|
||||
|
||||
```typescript
|
||||
// ✓ Good: Explicit assertion with meaningful message
|
||||
const treeNodes = await page.locator('[class*="TreeNode"]')
|
||||
const count = await treeNodes.count()
|
||||
expect(count).to.be.greaterThan(0, 'Topic tree should contain nodes')
|
||||
|
||||
// ✗ Bad: No assertion, only screenshot
|
||||
await page.screenshot({ path: 'test.png' })
|
||||
```
|
||||
|
||||
#### 4. Test Data-Driven Scenarios
|
||||
Write tests that describe the data flow:
|
||||
|
||||
```typescript
|
||||
it('Given messages sent to livingroom/lamp/state and livingroom/lamp/brightness, both should appear under livingroom/lamp', async function () {
|
||||
// Test implementation verifies the specific data flow
|
||||
})
|
||||
```
|
||||
|
||||
#### 5. Use Data Test Attributes
|
||||
Leverage `data-test-*` attributes for reliable selectors:
|
||||
|
||||
```typescript
|
||||
// ✓ Good: Use data-test attributes
|
||||
const topic = await page.locator('span[data-test-topic="kitchen"]')
|
||||
|
||||
// ⚠ Acceptable: Use role/text when data attributes aren't available
|
||||
const button = await page.locator('//button/span[contains(text(),"Connect")]')
|
||||
|
||||
// ✗ Bad: Rely on CSS classes that may change
|
||||
const topic = await page.locator('.MuiTreeItem-label')
|
||||
```
|
||||
|
||||
#### 6. Verify Multiple Aspects
|
||||
Test should verify both state and UI:
|
||||
|
||||
```typescript
|
||||
// Verify the action completed
|
||||
const isVisible = await disconnectButton.isVisible()
|
||||
expect(isVisible).to.be.true
|
||||
|
||||
// Capture screenshot for visual verification
|
||||
await page.screenshot({ path: 'test-screenshot-connection.png' })
|
||||
```
|
||||
|
||||
#### 7. Handle MQTT Asynchronous Nature
|
||||
Account for message propagation time:
|
||||
|
||||
```typescript
|
||||
// Publish message
|
||||
await mockClient.publish('topic/name', 'value')
|
||||
|
||||
// Wait for UI to update
|
||||
await page.locator(`text="value"`).waitFor({ timeout: 5000 })
|
||||
|
||||
// Verify state
|
||||
const value = await page.textContent('.message-value')
|
||||
expect(value).toBe('value')
|
||||
```
|
||||
|
||||
### Handling MQTT Asynchronous Operations
|
||||
|
||||
MQTT is inherently asynchronous. When writing tests:
|
||||
|
||||
- **Wait for message propagation**: Use proper wait strategies (e.g., `await page.waitForSelector()`, `await sleep()`)
|
||||
- **Don't assume immediate updates**: Messages take time to send, receive, and update the UI
|
||||
- **Use event-based waiting**: Wait for specific UI elements or state changes rather than fixed timeouts when possible
|
||||
- **Account for network latency**: MQTT broker communication involves network round trips
|
||||
|
||||
### Example Test Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Perform action (e.g., publish message)
|
||||
await publishMessage(topic, payload)
|
||||
|
||||
// 2. Wait for UI to update (not just arbitrary sleep)
|
||||
await page.waitForSelector(`text="${expectedValue}"`, { timeout: 5000 })
|
||||
|
||||
// 3. Verify state
|
||||
const value = await page.textContent('.message-value')
|
||||
expect(value).toBe(expectedValue)
|
||||
|
||||
// 4. Take screenshot for verification
|
||||
await page.screenshot({ path: 'test-result.png' })
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
yarn test
|
||||
|
||||
# Run specific test suites
|
||||
yarn test:app
|
||||
yarn test:backend
|
||||
yarn test:mcp
|
||||
|
||||
# Run linters
|
||||
yarn lint
|
||||
yarn lint:fix
|
||||
```
|
||||
|
||||
### Running UI Tests (yarn test:ui)
|
||||
|
||||
The UI tests require specific setup in the test environment:
|
||||
|
||||
**Prerequisites:**
|
||||
1. **Xvfb (X Virtual Framebuffer)** - Required for headless Electron testing
|
||||
```bash
|
||||
# Start Xvfb on display :99
|
||||
Xvfb :99 -screen 0 1024x720x24 -ac &
|
||||
export DISPLAY=:99
|
||||
```
|
||||
|
||||
2. **Mosquitto MQTT Broker** - Required for MQTT message testing
|
||||
```bash
|
||||
# Install mosquitto
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
1. **Login Page** (https://github.com/user-attachments/assets/383305e1-2169-433c-a668-5a05da0c343a)
|
||||
- Enter username and password from environment variables
|
||||
- Click "LOGIN" button
|
||||
|
||||
# Start mosquitto service
|
||||
sudo systemctl start mosquitto
|
||||
2. **Main Application After Login** (https://github.com/user-attachments/assets/cc4d665f-2665-4289-b2fc-dc4986f9ab5b)
|
||||
- Application loads with sidebar, topic tree, value panel, and publish panel
|
||||
- MQTT Connection modal appears automatically for first-time setup
|
||||
- Configure broker connection (host, port, credentials, etc.)
|
||||
- Click "CONNECT" to establish MQTT connection
|
||||
|
||||
# Verify it's running on port 1883
|
||||
sudo systemctl status mosquitto
|
||||
```
|
||||
3. **Application Features:**
|
||||
- Topic tree on the left shows MQTT topic hierarchy
|
||||
- Value panel shows selected topic's message content
|
||||
- Publish panel allows sending MQTT messages
|
||||
- Charts panel for numeric value visualization
|
||||
- Settings drawer for app configuration
|
||||
|
||||
3. **@types/node** - Required for TypeScript compilation
|
||||
### Debugging WebSocket Connection
|
||||
|
||||
1. **Check server logs:**
|
||||
```bash
|
||||
yarn add -D @types/node
|
||||
node dist/src/server.js 2>&1 | tee server.log
|
||||
```
|
||||
|
||||
**Running UI Tests:**
|
||||
```bash
|
||||
# Build the application first
|
||||
yarn build
|
||||
2. **Check browser WebSocket:**
|
||||
- DevTools → Network → WS tab
|
||||
- Look for socket.io connection
|
||||
- Check Messages tab for authentication handshake
|
||||
|
||||
# Run UI tests with proper display
|
||||
DISPLAY=:99 yarn test:ui
|
||||
3. **Common WebSocket issues:**
|
||||
- CORS errors: Check `ALLOWED_ORIGINS` environment variable
|
||||
- Authentication errors: Verify credentials in sessionStorage
|
||||
- Connection refused: Server not running or port blocked
|
||||
|
||||
### Development vs Production
|
||||
|
||||
**Development mode:**
|
||||
```bash
|
||||
yarn dev:server
|
||||
# Runs webpack-dev-server with hot reload
|
||||
# More verbose error messages
|
||||
# Source maps enabled
|
||||
```
|
||||
|
||||
**Common Issues:**
|
||||
- **"Timeout exceeded" in before hook**: Mosquitto is not running or not accessible on port 1883
|
||||
- **"Cannot find type definition file for 'node'"**: Run `yarn add -D @types/node`
|
||||
- **Electron fails to launch**: Xvfb is not running or DISPLAY variable not set
|
||||
- **Tests hang**: Check if old Electron/mosquitto processes are still running and kill them
|
||||
|
||||
**Environment Cleanup:**
|
||||
**Production mode:**
|
||||
```bash
|
||||
# Kill old Electron processes
|
||||
ps aux | grep electron | grep -v grep | awk '{print $2}' | xargs kill -9 2>/dev/null
|
||||
|
||||
# Kill old mosquitto processes (if running custom instance)
|
||||
ps aux | grep mosquitto | grep -v grep | awk '{print $2}' | xargs kill -9 2>/dev/null
|
||||
NODE_ENV=production yarn build:server
|
||||
NODE_ENV=production node dist/src/server.js
|
||||
# Minified bundles
|
||||
# Generic error messages (security)
|
||||
# HSTS enabled
|
||||
```
|
||||
|
||||
## MCP Introspection Testing
|
||||
|
||||
The project supports MCP (Model Context Protocol) for automated testing with Playwright:
|
||||
|
||||
- Use `yarn test:mcp` to run automated UI tests
|
||||
- Tests launch the app with remote debugging enabled on port 9222
|
||||
- Connect to `http://localhost:9222` via Chrome DevTools Protocol
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `app/` - Frontend React application
|
||||
- `backend/` - Backend models, tests, and connection management
|
||||
- `src/` - Electron main process and bindings
|
||||
- `src/spec/` - Test specifications including MCP introspection tests
|
||||
|
||||
## Code Style and Formatting
|
||||
|
||||
### Linting
|
||||
|
||||
The project uses TSLint with Airbnb config and Prettier for code formatting:
|
||||
|
||||
```bash
|
||||
# Run all linters
|
||||
yarn lint
|
||||
|
||||
# Run linters individually
|
||||
yarn lint:prettier # Check Prettier formatting
|
||||
yarn lint:tslint # Check TSLint rules
|
||||
yarn lint:spellcheck # Check spelling in code
|
||||
|
||||
# Auto-fix issues
|
||||
yarn lint:fix # Fix TSLint and Prettier issues
|
||||
yarn lint:tslint:fix # Fix TSLint issues only
|
||||
yarn lint:prettier:fix # Fix Prettier issues only
|
||||
```
|
||||
|
||||
### Code Style Rules
|
||||
|
||||
- **Semicolons**: Never use semicolons (enforced by TSLint and Prettier)
|
||||
- **Quotes**: Single quotes for strings
|
||||
- **Indentation**: 2 spaces
|
||||
- **Line length**: Maximum 120 characters (Prettier) / 200 characters (TSLint)
|
||||
- **Arrow functions**: No parentheses for single parameters (`x => x + 1`)
|
||||
- **Trailing commas**: Required for multiline objects and arrays (ES5 compatible)
|
||||
|
||||
### TypeScript Guidelines
|
||||
|
||||
- Enable strict null checks and no implicit any
|
||||
- Use TypeScript interfaces for data structures
|
||||
- Prefer `const` over `let`, avoid `var`
|
||||
- Use type inference when possible, explicit types when clarity is needed
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### Adding Dependencies
|
||||
|
||||
```bash
|
||||
# Add to root project
|
||||
yarn add <package-name>
|
||||
|
||||
# Add to app (frontend)
|
||||
cd app && yarn add <package-name>
|
||||
|
||||
# Add to backend
|
||||
cd backend && yarn add <package-name>
|
||||
|
||||
# Add dev dependencies
|
||||
yarn add -D <package-name>
|
||||
```
|
||||
|
||||
### Important Dependency Notes
|
||||
|
||||
- Main dependencies are in the root `package.json`
|
||||
- Frontend React app has its own dependencies in `app/package.json`
|
||||
- Backend models and logic have dependencies in `backend/package.json`
|
||||
- Always use `--frozen-lockfile` in CI to ensure reproducible builds
|
||||
- Run `yarn install` after pulling changes that modify `yarn.lock`
|
||||
|
||||
## Debugging
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# Start with hot reload for frontend
|
||||
yarn dev
|
||||
|
||||
# This runs two processes in parallel:
|
||||
# 1. webpack-dev-server for the React app (port varies)
|
||||
# 2. Electron in development mode with the --development flag
|
||||
```
|
||||
|
||||
### Debugging TypeScript
|
||||
|
||||
- Source maps are enabled in `tsconfig.json`
|
||||
- Use `ts-node` for running TypeScript files directly
|
||||
- Backend tests can be debugged with: `cd backend && yarn test-inspect`
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Build fails**: Clear `dist/` and `app/build/` directories, then rebuild
|
||||
- **Electron won't start**: Ensure `yarn build` completed successfully
|
||||
- **Tests fail**: Check if MQTT broker (mosquitto) is running for integration tests
|
||||
- **UI not updating**: In dev mode, ensure webpack-dev-server is running
|
||||
|
||||
## Deployment and Packaging
|
||||
|
||||
### Creating Releases
|
||||
|
||||
```bash
|
||||
# Prepare release (updates version, changelog)
|
||||
yarn prepare-release
|
||||
|
||||
# Package the application for distribution
|
||||
yarn package
|
||||
|
||||
# Package with Docker (for consistent builds)
|
||||
yarn package-with-docker
|
||||
```
|
||||
|
||||
### Release Workflow
|
||||
|
||||
- **Beta releases**: Create PR to `beta` branch with "feat:" or "fix:" commits
|
||||
- **Production releases**: Create PR to `release` branch with "feat:" or "fix:" commits
|
||||
- Semantic release automatically handles versioning and changelog
|
||||
- Builds are created for Windows, macOS, and Linux
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
- Output directory: `build/`
|
||||
- Supported formats: DMG (macOS), EXE/NSIS (Windows), AppImage/Snap (Linux), AppX (Windows Store)
|
||||
- Code signing is configured via `res/` directory certificates and provisioning profiles
|
||||
After `yarn build:server`, check:
|
||||
- `dist/src/server.js` - Compiled server code
|
||||
- `app/build/*.js` - Webpack bundles
|
||||
- `app/build/index.html` - Entry point HTML
|
||||
|
||||
## Important Notes
|
||||
### Troubleshooting Checklist
|
||||
|
||||
- Always run `yarn build` before starting the application
|
||||
- The app uses Electron (see `package.json` for version)
|
||||
- MQTT communication is handled via [mqttjs](https://github.com/mqttjs/MQTT.js)
|
||||
- All code changes should pass linting (`yarn lint`)
|
||||
- Node.js version requirement: >= 20
|
||||
- The project uses workspace-like structure with separate package.json files for app and backend
|
||||
- [ ] Node.js version >=24
|
||||
- [ ] `yarn install` completed without errors
|
||||
- [ ] TypeScript compilation successful (`npx tsc`)
|
||||
- [ ] Webpack build successful (check `app/build/` directory)
|
||||
- [ ] Server starts without errors
|
||||
- [ ] Can access http://localhost:3000
|
||||
- [ ] Login dialog appears
|
||||
- [ ] No CSP errors in console
|
||||
- [ ] WebSocket connects successfully
|
||||
- [ ] App renders after login
|
||||
|
||||
### Security Considerations
|
||||
|
||||
When debugging, be aware that:
|
||||
- `unsafe-eval` in CSP is required for webpack but reduces security
|
||||
- Credentials should never be hardcoded (use environment variables)
|
||||
- In production, use HTTPS with a reverse proxy (nginx/Apache)
|
||||
- Rate limiting is active (5 auth attempts per 15 min per IP)
|
||||
- File upload size limit is 16MB
|
||||
|
||||
### Related Files
|
||||
|
||||
- `src/server.ts` - Express server with security middleware
|
||||
- `app/webpack.browser.config.mjs` - Browser-specific webpack config
|
||||
- `app/src/browserEventBus.ts` - Socket.io client for browser mode
|
||||
- `app/src/components/BrowserAuthWrapper.tsx` - Authentication dialog
|
||||
- `app/src/index.tsx` - React app entry point with theme providers
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
name: Copilot Agent Setup
|
||||
name: 'Copilot Setup Steps'
|
||||
|
||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||
# allow manual testing through the repository's "Actions" tab
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb mosquitto
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
node-version: '24'
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Cache yarn dependencies
|
||||
uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
@@ -31,9 +44,6 @@ jobs:
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
run: yarn
|
||||
@@ -28,14 +28,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
- run: npm install -g yarn
|
||||
- run: yarn
|
||||
- id: create_token # get ReleaseBot access token
|
||||
uses: tibdex/github-app-token@v2
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.RELEASE_BOT_APP_ID }}
|
||||
private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
||||
- name: Semantic Release
|
||||
uses: cycjimmy/semantic-release-action@v4
|
||||
id: semantic # Need an `id` for output variables
|
||||
@@ -50,4 +50,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
options: --user root
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
@@ -28,6 +30,8 @@ jobs:
|
||||
options: --user root
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
@@ -53,6 +57,8 @@ jobs:
|
||||
options: --user root
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
@@ -90,10 +96,12 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
- name: Install Dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build Browser Mode
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
ref: gh-pages
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: '24'
|
||||
- run: npm install
|
||||
- run: npm run readme
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
|
||||
@@ -17,3 +17,6 @@ test-mcp-introspection.js
|
||||
|
||||
/data
|
||||
test-screenshot-*.png
|
||||
test-expand-*.png
|
||||
|
||||
app/.webpack-cache
|
||||
+114
@@ -112,6 +112,120 @@ Both Electron IPC and Socket.io implement the same `EventBusInterface`, allowing
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Deployment
|
||||
|
||||
**CRITICAL**: The following security measures must be implemented for production deployments:
|
||||
|
||||
#### 1. HTTPS/TLS Encryption
|
||||
Always use HTTPS in production to protect credentials and MQTT data in transit:
|
||||
|
||||
```bash
|
||||
# Use a reverse proxy like nginx or Apache with TLS
|
||||
# Example nginx configuration:
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Environment Variables for Credentials
|
||||
**NEVER** use generated credentials in production. Always set secure credentials via environment variables:
|
||||
|
||||
```bash
|
||||
export MQTT_EXPLORER_USERNAME=your_secure_username
|
||||
export MQTT_EXPLORER_PASSWORD=your_strong_password_min_12_chars
|
||||
export NODE_ENV=production
|
||||
yarn start:server
|
||||
```
|
||||
|
||||
#### 3. CORS Configuration
|
||||
Configure allowed origins instead of using the wildcard (`*`):
|
||||
|
||||
```bash
|
||||
# Single origin
|
||||
export ALLOWED_ORIGINS=https://mqtt-explorer.example.com
|
||||
|
||||
# Multiple origins (comma-separated)
|
||||
export ALLOWED_ORIGINS=https://app1.example.com,https://app2.example.com
|
||||
|
||||
yarn start:server
|
||||
```
|
||||
|
||||
In production with `NODE_ENV=production`, wildcard CORS is automatically disabled for security.
|
||||
|
||||
#### 4. Network Security
|
||||
- Deploy behind a firewall or VPN
|
||||
- Use IP whitelisting if possible
|
||||
- Implement network-level rate limiting
|
||||
- Monitor for suspicious connection patterns
|
||||
|
||||
#### 5. File Upload Security
|
||||
The server implements several protections against malicious file uploads:
|
||||
- Maximum file size: 16MB (configurable via `MAX_FILE_SIZE` constant)
|
||||
- Path traversal protection via filename sanitization
|
||||
- Files stored in isolated directories
|
||||
- Real path validation to prevent directory escapes
|
||||
|
||||
#### 6. Authentication Security
|
||||
The server implements multiple layers of authentication security:
|
||||
- **Password Hashing**: bcrypt with 10 rounds
|
||||
- **Timing Attack Protection**: Constant-time string comparison for usernames
|
||||
- **Rate Limiting**: Maximum 5 failed attempts per IP per 15 minutes
|
||||
- **Session Tracking**: Failed attempts are tracked per client IP
|
||||
- **No Credential Logging**: In production mode, credentials are not logged
|
||||
|
||||
#### 7. HTTP Security Headers
|
||||
The server uses helmet.js to set security headers:
|
||||
- Content Security Policy (CSP)
|
||||
- HTTP Strict Transport Security (HSTS) in production
|
||||
- X-Content-Type-Options: nosniff
|
||||
- X-Frame-Options: DENY
|
||||
- X-XSS-Protection
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Rotate Credentials Regularly**: Change authentication credentials periodically
|
||||
2. **Monitor Logs**: Watch for authentication failures and unusual patterns
|
||||
3. **Keep Dependencies Updated**: Run `yarn audit` regularly
|
||||
4. **Limit Network Exposure**: Don't expose the server directly to the internet
|
||||
5. **Use Strong Passwords**: Minimum 12 characters with mixed case, numbers, and symbols
|
||||
6. **Enable Logging**: Monitor access logs and error logs
|
||||
7. **Regular Backups**: Back up configuration and certificate data
|
||||
8. **Principle of Least Privilege**: Run the server with minimal required permissions
|
||||
|
||||
### Vulnerability Reporting
|
||||
|
||||
If you discover a security vulnerability, please report it via:
|
||||
- GitHub Security Advisories
|
||||
- Email to the maintainer
|
||||
- Do NOT create public issues for security vulnerabilities
|
||||
|
||||
### Security Audit Log
|
||||
|
||||
- **2024-12**: Initial security review and hardening
|
||||
- Added helmet.js for HTTP security headers
|
||||
- Implemented rate limiting for authentication
|
||||
- Added path traversal protection
|
||||
- Implemented constant-time comparison for credentials
|
||||
- Added input validation and size limits
|
||||
- Removed credential logging in production
|
||||
- Added configurable CORS origins
|
||||
- Created comprehensive security test suite
|
||||
|
||||
## Security Considerations (Legacy)
|
||||
|
||||
1. **HTTPS**: For production, always use HTTPS to encrypt credentials and MQTT data
|
||||
2. **Authentication**: Keep credentials secure and rotate them regularly
|
||||
3. **Network**: Ensure the server is on a trusted network or behind a firewall
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20
|
||||
FROM node:24
|
||||
|
||||
RUN DEBIAN_FRONTEND="noninteractive" apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nano ffmpeg xvfb git-core tmux locales mosquitto x11vnc
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
When redistributing, the attribution page may not be altered or made less accessible without explicit approval.
|
||||
When distributing, the attribution and donation page may not be altered or made less accessible without explicit approval.
|
||||
|
||||
# Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# macOS Notarization Setup
|
||||
|
||||
This document explains how to set up notarization for macOS builds of MQTT Explorer.
|
||||
|
||||
## Overview
|
||||
|
||||
macOS notarization is a security feature required by Apple for all software distributed outside the Mac App Store. Starting with macOS 10.15 (Catalina), all software must be notarized to run without warnings on macOS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. An active Apple Developer account
|
||||
2. Xcode command line tools installed on the build machine
|
||||
3. An app-specific password for notarization
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Create an App-Specific Password
|
||||
|
||||
1. Sign in to [appleid.apple.com](https://appleid.apple.com)
|
||||
2. Navigate to "Sign-In and Security" section
|
||||
3. Under "App-Specific Passwords", click "Generate an app-specific password"
|
||||
4. Enter a descriptive name (e.g., "MQTT Explorer Notarization")
|
||||
5. Copy the generated password (you won't be able to see it again)
|
||||
|
||||
### 2. Find Your Team ID
|
||||
|
||||
1. Sign in to [developer.apple.com](https://developer.apple.com/account)
|
||||
2. Navigate to "Membership Details"
|
||||
3. Copy your Team ID (a 10-character alphanumeric string)
|
||||
|
||||
### 3. Configure GitHub Secrets
|
||||
|
||||
Add the following secrets to your GitHub repository:
|
||||
|
||||
- `APPLE_ID`: Your Apple ID email address (e.g., `your.email@example.com`)
|
||||
- `APPLE_APP_SPECIFIC_PASSWORD`: The app-specific password created in step 1
|
||||
- `APPLE_TEAM_ID`: Your Team ID from step 2
|
||||
|
||||
To add secrets:
|
||||
1. Go to your repository on GitHub
|
||||
2. Navigate to Settings → Secrets and variables → Actions
|
||||
3. Click "New repository secret" for each of the above
|
||||
|
||||
## How It Works
|
||||
|
||||
### Build Configuration
|
||||
|
||||
The notarization process is configured in `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"mac": {
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "res/entitlements.mac.plist",
|
||||
"entitlementsInherit": "res/entitlements.mac.inherit.plist"
|
||||
},
|
||||
"afterSign": "./dist/scripts/notarize.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Notarization Script
|
||||
|
||||
The `scripts/notarize.ts` script handles the notarization process:
|
||||
|
||||
1. Checks if the build is for macOS
|
||||
2. Verifies that required environment variables are set
|
||||
3. Submits the app to Apple's notarization service
|
||||
4. Waits for notarization to complete
|
||||
5. Staples the notarization ticket to the app
|
||||
|
||||
### Entitlements
|
||||
|
||||
Different entitlements are used for different build types:
|
||||
|
||||
- **DMG builds** (regular distribution):
|
||||
- `res/entitlements.mac.plist` - Main entitlements
|
||||
- `res/entitlements.mac.inherit.plist` - Inherited entitlements for child processes
|
||||
|
||||
- **MAS builds** (Mac App Store):
|
||||
- `res/entitlements.mas.plist` - App Store specific entitlements
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
The GitHub Actions workflow (`platform-builds.yml`) automatically:
|
||||
|
||||
1. Builds the macOS app when code is pushed to `release` or `beta` branches
|
||||
2. Signs the app with the developer certificate
|
||||
3. Notarizes the app using the configured secrets
|
||||
4. Publishes the notarized app to GitHub releases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notarization Fails
|
||||
|
||||
If notarization fails, check:
|
||||
|
||||
1. **Credentials**: Ensure all three secrets are correctly set in GitHub
|
||||
2. **App-specific password**: Verify it hasn't expired or been revoked
|
||||
3. **Team ID**: Confirm it matches your developer account
|
||||
4. **Entitlements**: Ensure the entitlements files are valid and appropriate for your app
|
||||
|
||||
### Checking Notarization Status
|
||||
|
||||
You can check the notarization status of a built app:
|
||||
|
||||
```bash
|
||||
# Check if an app is notarized
|
||||
spctl -a -vv /path/to/MQTT\ Explorer.app
|
||||
|
||||
# Check notarization history
|
||||
xcrun notarytool history --apple-id your.email@example.com --team-id YOUR_TEAM_ID
|
||||
```
|
||||
|
||||
### Local Testing
|
||||
|
||||
To test notarization locally:
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export APPLE_ID="your.email@example.com"
|
||||
export APPLE_APP_SPECIFIC_PASSWORD="your-app-specific-password"
|
||||
export APPLE_TEAM_ID="YOUR_TEAM_ID"
|
||||
|
||||
# Build and notarize
|
||||
yarn build
|
||||
yarn package mac
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never commit Apple credentials to the repository
|
||||
- Use app-specific passwords, not your main Apple ID password
|
||||
- Rotate app-specific passwords periodically
|
||||
- Limit access to GitHub secrets to trusted maintainers only
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Notarization Documentation](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution)
|
||||
- [electron-builder Code Signing](https://www.electron.build/code-signing)
|
||||
- [@electron/notarize](https://github.com/electron/notarize)
|
||||
@@ -116,6 +116,10 @@ yarn ui-test
|
||||
Create a PR to `release` branch.
|
||||
There needs to be a "feat: some new feature" or "fix: some bugfix" commit for a new release to be created
|
||||
|
||||
### macOS Notarization
|
||||
|
||||
macOS builds are automatically notarized during the release process. To set up notarization credentials, see [NOTARIZATION.md](NOTARIZATION.md).
|
||||
|
||||
## Create a beta release
|
||||
|
||||
Create a PR to `beta` branch. A "feat" or "fix" commit is necessary to create a new version.
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest release of MQTT Explorer.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.4.x | :white_check_mark: |
|
||||
| < 0.4 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security vulnerabilities seriously. If you discover a security issue, please follow these steps:
|
||||
|
||||
### How to Report
|
||||
|
||||
1. **DO NOT** create a public GitHub issue for security vulnerabilities
|
||||
2. Report via one of these channels:
|
||||
- GitHub Security Advisories (preferred): https://github.com/thomasnordquist/MQTT-Explorer/security/advisories/new
|
||||
- Email the maintainer directly
|
||||
3. Include the following information:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Any suggested fixes (optional)
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Acknowledgment**: We will acknowledge receipt of your report within 48 hours
|
||||
- **Updates**: We will provide updates on the status of your report within 7 days
|
||||
- **Fix Timeline**: We aim to release security fixes within 30 days for critical issues
|
||||
- **Credit**: With your permission, we will credit you in the security advisory and release notes
|
||||
|
||||
## Security Features
|
||||
|
||||
### Browser Mode Security
|
||||
|
||||
MQTT Explorer's browser mode includes several security features:
|
||||
|
||||
#### Authentication
|
||||
- **bcrypt Password Hashing**: All passwords are hashed with bcrypt (10 rounds)
|
||||
- **Constant-Time Comparison**: Username comparison uses crypto.timingSafeEqual() to prevent timing attacks
|
||||
- **Environment Variable Configuration**: Credentials can be set via environment variables for production
|
||||
- **Automatic Credential Generation**: Secure random credentials generated if not provided
|
||||
|
||||
#### Rate Limiting
|
||||
- **Authentication Rate Limiting**: Maximum 5 failed authentication attempts per IP per 15 minutes
|
||||
- **Per-IP Tracking**: Failed attempts tracked separately for each client IP
|
||||
- **Automatic Reset**: Rate limit counters automatically reset after 15 minutes
|
||||
|
||||
#### HTTP Security Headers (helmet.js)
|
||||
- **Content Security Policy (CSP)**: Restricts resource loading to prevent XSS attacks
|
||||
- **HTTP Strict Transport Security (HSTS)**: Enforces HTTPS in production
|
||||
- **X-Content-Type-Options**: Prevents MIME type sniffing
|
||||
- **X-Frame-Options**: Prevents clickjacking attacks
|
||||
- **X-XSS-Protection**: Enables browser XSS protection
|
||||
|
||||
#### Input Validation
|
||||
- **File Size Limits**: Maximum 16MB for file uploads
|
||||
- **Path Traversal Protection**: All file paths validated and sanitized
|
||||
- **Filename Sanitization**: Removes path separators, null bytes, and validates against traversal patterns
|
||||
- **Real Path Validation**: Ensures resolved paths stay within allowed directories
|
||||
- **Base64 Validation**: All file data properly validated before processing
|
||||
|
||||
#### CORS Configuration
|
||||
- **Configurable Origins**: CORS origins configurable via ALLOWED_ORIGINS environment variable
|
||||
- **Production Restrictions**: Wildcard CORS automatically disabled in production
|
||||
- **Credential Support**: CORS configured with credentials: true for authenticated requests
|
||||
|
||||
#### Error Handling
|
||||
- **Generic Error Messages**: Detailed errors only shown in development mode
|
||||
- **No Information Leakage**: Error messages sanitized to prevent information disclosure
|
||||
- **Secure Logging**: Sensitive information not logged in production
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### For Server Deployment
|
||||
|
||||
1. **Always Use HTTPS in Production**
|
||||
- Use a reverse proxy (nginx, Apache) with TLS certificates
|
||||
- Never expose the Node.js server directly to the internet
|
||||
- Use Let's Encrypt for free TLS certificates
|
||||
|
||||
2. **Set Strong Credentials**
|
||||
```bash
|
||||
export MQTT_EXPLORER_USERNAME=your_secure_username
|
||||
export MQTT_EXPLORER_PASSWORD=your_strong_password_min_12_chars
|
||||
export NODE_ENV=production
|
||||
```
|
||||
|
||||
3. **Configure CORS Properly**
|
||||
```bash
|
||||
# Single origin
|
||||
export ALLOWED_ORIGINS=https://mqtt-explorer.example.com
|
||||
|
||||
# Multiple origins
|
||||
export ALLOWED_ORIGINS=https://app1.example.com,https://app2.example.com
|
||||
```
|
||||
|
||||
4. **Network Security**
|
||||
- Deploy behind a firewall or VPN
|
||||
- Use IP whitelisting when possible
|
||||
- Implement network-level rate limiting
|
||||
- Monitor access logs regularly
|
||||
|
||||
5. **Keep Dependencies Updated**
|
||||
```bash
|
||||
yarn audit
|
||||
yarn upgrade-interactive
|
||||
```
|
||||
|
||||
6. **Regular Security Audits**
|
||||
- Run security tests: `yarn test:security`
|
||||
- Review access logs for suspicious activity
|
||||
- Monitor authentication failures
|
||||
- Check for outdated dependencies
|
||||
|
||||
### For MQTT Connections
|
||||
|
||||
1. **Use TLS/SSL**: Always connect to MQTT brokers using TLS encryption
|
||||
2. **Strong Credentials**: Use unique, strong passwords for MQTT authentication
|
||||
3. **Certificate Validation**: Verify broker certificates in production
|
||||
4. **Least Privilege**: Connect with minimal required permissions
|
||||
|
||||
## Security Testing
|
||||
|
||||
The project includes comprehensive security tests:
|
||||
|
||||
```bash
|
||||
# Run all tests including security tests
|
||||
yarn test
|
||||
|
||||
# Run only security tests
|
||||
npx mocha --require source-map-support/register dist/src/spec/security-tests.spec.js
|
||||
```
|
||||
|
||||
Security tests cover:
|
||||
- Path traversal attack prevention
|
||||
- Input validation and sanitization
|
||||
- Authentication security
|
||||
- CORS configuration
|
||||
- Rate limiting
|
||||
- Error handling
|
||||
- Data sanitization
|
||||
|
||||
## Security Audit History
|
||||
|
||||
### December 2024 - Initial Security Review
|
||||
- Added helmet.js for HTTP security headers
|
||||
- Implemented rate limiting for authentication
|
||||
- Added path traversal protection with sanitization
|
||||
- Implemented constant-time comparison for credentials
|
||||
- Added input validation and size limits
|
||||
- Removed credential logging in production
|
||||
- Added configurable CORS origins
|
||||
- Created comprehensive security test suite (19 tests)
|
||||
- Enhanced documentation with security best practices
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Browser Mode
|
||||
- File system access limited to server-side directories
|
||||
- No native OS dialogs (uses browser file input)
|
||||
- Session management is stateless (no persistent sessions)
|
||||
|
||||
### Desktop Mode (Electron)
|
||||
- Inherits security model from Electron framework
|
||||
- IPC communication between renderer and main process
|
||||
- No network exposure by default
|
||||
|
||||
## Recommended Security Tools
|
||||
|
||||
- **Dependency Scanning**: Dependabot, Snyk, or npm audit
|
||||
- **SAST**: SonarQube, ESLint security plugins
|
||||
- **Container Scanning**: If using Docker deployment
|
||||
- **TLS Testing**: SSL Labs, testssl.sh
|
||||
- **Penetration Testing**: OWASP ZAP, Burp Suite
|
||||
|
||||
## References
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
|
||||
- [Express Security Best Practices](https://expressjs.com/en/advanced/best-practice-security.html)
|
||||
- [helmet.js Documentation](https://helmetjs.github.io/)
|
||||
- [MQTT Security](https://mqtt.org/mqtt-specification/)
|
||||
|
||||
## Contact
|
||||
|
||||
For security-related questions or concerns:
|
||||
- GitHub Security Advisories: https://github.com/thomasnordquist/MQTT-Explorer/security/advisories
|
||||
- Project Issues (for non-sensitive topics): https://github.com/thomasnordquist/MQTT-Explorer/issues
|
||||
|
||||
Thank you for helping keep MQTT Explorer secure!
|
||||
+58
-55
@@ -6,8 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"dev": "node_modules/.bin/webpack-dev-server --mode development --progress",
|
||||
"test": "cross-env TS_NODE_PROJECT=test/tsconfig.json yarn mochatest",
|
||||
"mochatest": "mocha --require ts-node/register --require source-map-support/register --recursive src/*/**/*.spec.ts"
|
||||
"test": "mocha --require tsx --require source-map-support/register --recursive src/*/**/*.spec.ts",
|
||||
"mochatest": "mocha --require tsx --require source-map-support/register --recursive src/*/**/*.spec.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -15,24 +15,26 @@
|
||||
"author": "",
|
||||
"license": "CC-BY-ND-4.0",
|
||||
"dependencies": {
|
||||
"@material-ui/core": "4.12",
|
||||
"@material-ui/icons": "^4",
|
||||
"@material-ui/lab": "^4.0.0-alpha",
|
||||
"@material-ui/styles": "4.11",
|
||||
"@types/react-transition-group": "^4",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@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",
|
||||
"axios": "^0.28.0",
|
||||
"compare-versions": "^3.5.0",
|
||||
"copy-text-to-clipboard": "^2.1.0",
|
||||
"d3": "^5.9.7",
|
||||
"d3-shape": "^1.3.5",
|
||||
"diff": "^4.0.1",
|
||||
"dot-prop": "^5.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
"copy-text-to-clipboard": "^3.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"diff": "^7.0.0",
|
||||
"dot-prop": "^5.3.0",
|
||||
"events": "^3.3.0",
|
||||
"get-value": "^3.0.1",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"immutable": "^4.3.7",
|
||||
"in-viewport": "^3.6.0",
|
||||
"js-base64": "^2.5.1",
|
||||
"js-base64": "^3.7.8",
|
||||
"json-to-ast": "^2.1.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
@@ -41,55 +43,56 @@
|
||||
"os-browserify": "^0.3.0",
|
||||
"parse-duration": "^0.1.1",
|
||||
"path-browserify": "^1.0.1",
|
||||
"prismjs": "^1.15.0",
|
||||
"react": "^16.11",
|
||||
"react-ace": "^8",
|
||||
"react-dom": "^16.7.0",
|
||||
"react-redux": "^7.0.3",
|
||||
"react-resize-detector": "^4.1.4",
|
||||
"react-split-pane": "^0.1.85",
|
||||
"react-transition-group": "^4",
|
||||
"react-vis": "^1.11.6",
|
||||
"redux": "^4.0.1",
|
||||
"redux-batched-actions": "0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"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",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-vis": "^1.12.1",
|
||||
"redux": "^5.0.1",
|
||||
"redux-batched-actions": "^0.5.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"sha1": "^1.1.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"url": "^0.11.4",
|
||||
"uuid": "7"
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.17.2",
|
||||
"@types/d3": "^5.7.2",
|
||||
"@types/diff": "^4.0.1",
|
||||
"@types/get-value": "^3.0.1",
|
||||
"@types/node": "^12.7.8",
|
||||
"@types/prismjs": "^1.9.1",
|
||||
"@types/react": "^16.9.4",
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/react-redux": "^7.0.9",
|
||||
"@types/react-resize-detector": "^4.0.1",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/diff": "^7.0.0",
|
||||
"@types/get-value": "^3.0.5",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@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",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"@types/uuid": "^7.0.2",
|
||||
"@types/vis": "^4.21.9",
|
||||
"chai": "^4.2.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"css-loader": "^3.0.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@types/vis": "^4.21.24",
|
||||
"chai": "^4.5.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"lodash": "^4.17.21",
|
||||
"mocha": "^10.4.0",
|
||||
"moment": "^2.29.1",
|
||||
"node-loader": "^0.6.0",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"style-loader": "^1",
|
||||
"mocha": "^10.8.2",
|
||||
"moment": "^2.30.1",
|
||||
"node-loader": "^2.0.0",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "^4.5.5",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.4"
|
||||
"typescript": "^5.9.3",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": "^39"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,7 @@ export const setTopic = (topic?: string): Action => {
|
||||
}
|
||||
|
||||
export const openFile =
|
||||
(encoding: 'utf8' = 'utf8') =>
|
||||
(encoding: BufferEncoding = 'utf8') =>
|
||||
async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
try {
|
||||
const file = await getFileContent(encoding)
|
||||
@@ -31,7 +31,7 @@ type FileParameters = {
|
||||
name: string
|
||||
data: string
|
||||
}
|
||||
async function getFileContent(encoding: string): Promise<FileParameters | undefined> {
|
||||
async function getFileContent(encoding: BufferEncoding): Promise<FileParameters | undefined> {
|
||||
const rejectReasons = {
|
||||
noFileSelected: 'No file selected',
|
||||
errorReadingFile: 'Error reading file',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { batchActions } from 'redux-batched-actions'
|
||||
import { globalActions } from './'
|
||||
import { setTopic } from './Publish'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
const debounce = require('lodash.debounce')
|
||||
import debounce from 'lodash.debounce'
|
||||
export { clearTopic } from './clearTopic'
|
||||
|
||||
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Browser-specific EventBus implementation using Socket.io
|
||||
// This file contains the socket.io-client dependency which belongs in the app layer
|
||||
import io, { Socket } from 'socket.io-client'
|
||||
import { SocketIOClientEventBus } from '../../events/EventSystem/SocketIOClientEventBus'
|
||||
import { Rpc } from '../../events/EventSystem/Rpc'
|
||||
|
||||
// Get auth from sessionStorage or use empty (will show login dialog)
|
||||
let username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : ''
|
||||
let password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : ''
|
||||
|
||||
// Connect to the server (same origin in browser mode)
|
||||
const socket: Socket = io({
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: Infinity,
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: false, // Don't auto-connect, we'll connect manually after checking credentials
|
||||
})
|
||||
|
||||
// Handle connection errors
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error.message)
|
||||
|
||||
// Check if it's an authentication error
|
||||
if (error.message.includes('Invalid credentials') ||
|
||||
error.message.includes('Authentication required') ||
|
||||
error.message.includes('Too many')) {
|
||||
// Clear invalid credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Dispatch custom event that BrowserAuthWrapper can listen to
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auth-error', {
|
||||
detail: { message: error.message }
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('Socket disconnected:', reason)
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected successfully')
|
||||
|
||||
// Dispatch custom event that BrowserAuthWrapper can listen to
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auth-success', {
|
||||
detail: { message: 'Authentication successful' }
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update socket authentication credentials and attempt to reconnect
|
||||
* @param newUsername New username
|
||||
* @param newPassword New password
|
||||
*/
|
||||
export function updateSocketAuth(newUsername: string, newPassword: string) {
|
||||
username = newUsername
|
||||
password = newPassword
|
||||
|
||||
// Update socket auth
|
||||
socket.auth = {
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
}
|
||||
|
||||
// Store in sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('mqtt-explorer-username', newUsername)
|
||||
sessionStorage.setItem('mqtt-explorer-password', newPassword)
|
||||
}
|
||||
|
||||
// Disconnect if connected, then reconnect with new credentials
|
||||
if (socket.connected) {
|
||||
socket.disconnect()
|
||||
}
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the socket (used on initial page load)
|
||||
*/
|
||||
export function connectSocket() {
|
||||
if (!socket.connected) {
|
||||
socket.connect()
|
||||
}
|
||||
}
|
||||
|
||||
export const rendererEvents = new SocketIOClientEventBus(socket)
|
||||
export const rendererRpc = new Rpc(rendererEvents)
|
||||
|
||||
// Export socket instance for error monitoring
|
||||
export const browserSocket = socket
|
||||
|
||||
// In browser mode, the backend is on the server
|
||||
// For compatibility, export same instances (renderer communicates with server backend via socket)
|
||||
export const backendEvents = rendererEvents
|
||||
export const backendRpc = rendererRpc
|
||||
|
||||
// Re-export all events from the events module so imports work correctly
|
||||
export * from '../../events/Events'
|
||||
export * from '../../events/EventsV2'
|
||||
export * from '../../events/EventSystem/EventDispatcher'
|
||||
export * from '../../events/EventSystem/EventBusInterface'
|
||||
@@ -1,6 +1,6 @@
|
||||
import ConfirmationDialog from './ConfirmationDialog'
|
||||
import ConnectionSetup from './ConnectionSetup/ConnectionSetup'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import Notification from './Layout/Notification'
|
||||
import React from 'react'
|
||||
@@ -11,7 +11,8 @@ import { bindActionCreators } from 'redux'
|
||||
import { ConfirmationRequest } from '../reducers/Global'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions } from '../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
;(window as any).global = window
|
||||
|
||||
const Settings = React.lazy(() => import('./SettingsDrawer/Settings'))
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
import { updateSocketAuth, connectSocket } from '../browserEventBus'
|
||||
import { isBrowserMode } from '../utils/browserMode'
|
||||
|
||||
interface BrowserAuthWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const isBrowserMode =
|
||||
typeof window !== 'undefined' && (typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
|
||||
|
||||
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)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isBrowserMode) {
|
||||
@@ -20,34 +21,86 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for successful authentication from socket
|
||||
const handleAuthSuccess = (event: CustomEvent) => {
|
||||
console.log('Authentication successful')
|
||||
setIsAuthenticated(true)
|
||||
setShowLogin(false)
|
||||
setLoginError(undefined)
|
||||
setWaitTimeSeconds(undefined)
|
||||
setIsConnecting(false)
|
||||
}
|
||||
|
||||
// Listen for authentication errors from socket
|
||||
const handleAuthError = (event: CustomEvent) => {
|
||||
const errorMessage = event.detail?.message || 'Authentication failed'
|
||||
console.error('Authentication error:', errorMessage)
|
||||
|
||||
// Clear authentication state
|
||||
setIsAuthenticated(false)
|
||||
setShowLogin(true)
|
||||
setIsConnecting(false)
|
||||
|
||||
// Extract wait time from error message (e.g., "Please wait 30 seconds")
|
||||
const waitTimeMatch = errorMessage.match(/(\d+)\s+seconds?/)
|
||||
if (waitTimeMatch) {
|
||||
const seconds = parseInt(waitTimeMatch[1], 10)
|
||||
// Add a few seconds margin to the countdown
|
||||
setWaitTimeSeconds(seconds + 3)
|
||||
} else {
|
||||
setWaitTimeSeconds(undefined)
|
||||
}
|
||||
|
||||
// Set user-friendly error message based on error type
|
||||
// Error messages from server already include wait times
|
||||
if (errorMessage.includes('Too many failed authentication attempts')) {
|
||||
setLoginError(errorMessage)
|
||||
} else if (errorMessage.includes('Invalid credentials')) {
|
||||
setLoginError(errorMessage)
|
||||
} else if (errorMessage.includes('Authentication required')) {
|
||||
setLoginError('Please enter your username and password.')
|
||||
setWaitTimeSeconds(undefined)
|
||||
} else {
|
||||
setLoginError('Authentication failed. Please try again.')
|
||||
setWaitTimeSeconds(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Try to use stored credentials
|
||||
setIsAuthenticated(true)
|
||||
// Credentials exist, try to connect with them
|
||||
setIsConnecting(true)
|
||||
connectSocket()
|
||||
} else {
|
||||
// Show login dialog
|
||||
// No credentials, show login dialog
|
||||
setShowLogin(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
|
||||
window.removeEventListener('mqtt-auth-error', handleAuthError as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
const handleLogin = (username: string, password: string) => {
|
||||
try {
|
||||
// Store credentials in session storage
|
||||
sessionStorage.setItem('mqtt-explorer-username', username)
|
||||
sessionStorage.setItem('mqtt-explorer-password', password)
|
||||
|
||||
// The socket will use these credentials on next connection
|
||||
setIsAuthenticated(true)
|
||||
setShowLogin(false)
|
||||
// Clear any previous error
|
||||
setLoginError(undefined)
|
||||
|
||||
// Reload to reinitialize socket with new auth
|
||||
window.location.reload()
|
||||
setWaitTimeSeconds(undefined)
|
||||
setIsConnecting(true)
|
||||
|
||||
// Update socket auth and reconnect (no page reload needed)
|
||||
updateSocketAuth(username, password)
|
||||
} catch (error) {
|
||||
setLoginError('Login failed. Please check your credentials.')
|
||||
console.error('Failed to update socket auth:', error)
|
||||
setLoginError('Failed to connect. Please try again.')
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +110,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} />
|
||||
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} waitTimeSeconds={waitTimeSeconds} />
|
||||
}
|
||||
|
||||
return <>{props.children}</>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import NoData from './NoData'
|
||||
import NumberFormatter from '../helper/NumberFormatter'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import React, { memo, useCallback, useRef, useEffect } from 'react'
|
||||
import TooltipComponent from './TooltipComponent'
|
||||
import { default as ReactResizeDetector } from 'react-resize-detector'
|
||||
import { emphasize } from '@material-ui/core/styles'
|
||||
import { useResizeDetector } from 'react-resize-detector'
|
||||
import { emphasize, useTheme } from '@mui/material/styles'
|
||||
import { mapCurveType } from './mapCurveType'
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import { Point, Tooltip } from './Model'
|
||||
import { Theme, withTheme } from '@material-ui/core'
|
||||
import { useCustomXDomain } from './effects/useCustomXDomain'
|
||||
import { useCustomYDomain } from './effects/useCustomYDomain'
|
||||
import 'react-vis/dist/style.css'
|
||||
@@ -17,89 +16,85 @@ const abbreviate = require('number-abbreviate')
|
||||
|
||||
export interface Props {
|
||||
data: Array<{ x: number; y: number }>
|
||||
theme: Theme
|
||||
interpolation?: PlotCurveTypes
|
||||
range?: [number?, number?]
|
||||
timeRangeStart?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export default withTheme(
|
||||
memo((props: Props) => {
|
||||
const [width, setWidth] = React.useState(300)
|
||||
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
|
||||
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
|
||||
export default memo((props: Props) => {
|
||||
const theme = useTheme()
|
||||
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
|
||||
const { width = 300, ref } = useResizeDetector()
|
||||
|
||||
const hintFormatter = React.useCallback(
|
||||
(point: any) => [
|
||||
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
|
||||
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
|
||||
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
|
||||
],
|
||||
[]
|
||||
)
|
||||
const hintFormatter = React.useCallback(
|
||||
(point: any) => [
|
||||
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
|
||||
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
|
||||
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setTooltip(undefined)
|
||||
}, [])
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setTooltip(undefined)
|
||||
}, [])
|
||||
|
||||
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
|
||||
if (!something) {
|
||||
return
|
||||
}
|
||||
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
|
||||
}, [])
|
||||
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
|
||||
if (!something) {
|
||||
return
|
||||
}
|
||||
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
|
||||
}, [])
|
||||
|
||||
const paletteColor =
|
||||
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
|
||||
const color = props.color ? props.color : paletteColor
|
||||
const paletteColor =
|
||||
theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light
|
||||
const color = props.color ? props.color : paletteColor
|
||||
|
||||
const highlightSelectedPoint = useCallback(
|
||||
(point: Point) => {
|
||||
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
|
||||
return highlight ? emphasize(color, 0.8) : color
|
||||
},
|
||||
[tooltip, color]
|
||||
)
|
||||
const highlightSelectedPoint = useCallback(
|
||||
(point: Point) => {
|
||||
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
|
||||
return highlight ? emphasize(color, 0.8) : color
|
||||
},
|
||||
[tooltip, color]
|
||||
)
|
||||
|
||||
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
|
||||
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
|
||||
|
||||
const xDomain = useCustomXDomain(props)
|
||||
const yDomain = useCustomYDomain(props)
|
||||
const xDomain = useCustomXDomain(props)
|
||||
const yDomain = useCustomYDomain(props)
|
||||
|
||||
const data = props.data
|
||||
const hasData = data.length > 0
|
||||
const dummyDomain = [-1, 1]
|
||||
const dummyData = [{ x: -2, y: -2 }]
|
||||
return (
|
||||
<div>
|
||||
<div style={{ height: '150px', width: '100%', position: 'relative' }}>
|
||||
{data.length === 0 ? <NoData /> : null}
|
||||
<XYPlot
|
||||
width={width}
|
||||
height={180}
|
||||
yDomain={hasData ? yDomain : dummyDomain}
|
||||
xDomain={hasData ? xDomain : dummyDomain}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<HorizontalGridLines />
|
||||
<YAxis width={45} tickFormat={formatYAxis} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
colorType="literal"
|
||||
getColor={highlightSelectedPoint}
|
||||
onValueMouseOver={showTooltip}
|
||||
size={3}
|
||||
data={hasData ? data : dummyData}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
|
||||
<TooltipComponent tooltip={tooltip} theme={props.theme} />
|
||||
</Hint>
|
||||
</XYPlot>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
|
||||
</div>
|
||||
const data = props.data
|
||||
const hasData = data.length > 0
|
||||
const dummyDomain = [-1, 1]
|
||||
const dummyData = [{ x: -2, y: -2 }]
|
||||
return (
|
||||
<div>
|
||||
<div ref={ref} style={{ height: '150px', width: '100%', position: 'relative' }}>
|
||||
{data.length === 0 ? <NoData /> : null}
|
||||
<XYPlot
|
||||
width={width || 300}
|
||||
height={180}
|
||||
yDomain={hasData ? yDomain : dummyDomain}
|
||||
xDomain={hasData ? xDomain : dummyDomain}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<HorizontalGridLines />
|
||||
<YAxis width={45} tickFormat={formatYAxis} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
colorType="literal"
|
||||
getColor={highlightSelectedPoint}
|
||||
onValueMouseOver={showTooltip}
|
||||
size={3}
|
||||
data={hasData ? data : dummyData}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
|
||||
<TooltipComponent tooltip={tooltip} />
|
||||
</Hint>
|
||||
</XYPlot>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
|
||||
function NoData() {
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { memo } from 'react'
|
||||
import { fade } from '@material-ui/core/styles'
|
||||
import { Fade, Grow, Paper, Popper, Theme, Typography, withTheme } from '@material-ui/core'
|
||||
import { alpha as fade, useTheme } from '@mui/material/styles'
|
||||
import { Fade, Grow, Paper, Popper, Typography } from '@mui/material'
|
||||
import { Tooltip } from './Model'
|
||||
|
||||
function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
|
||||
function TooltipComponent(props: { tooltip?: Tooltip }) {
|
||||
const theme = useTheme()
|
||||
const { tooltip } = props
|
||||
return (
|
||||
<Popper
|
||||
@@ -26,9 +27,9 @@ function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
|
||||
padding: '4px',
|
||||
marginTop: '-12px',
|
||||
backgroundColor: fade(
|
||||
props.theme.palette.type === 'light'
|
||||
? props.theme.palette.background.paper
|
||||
: props.theme.palette.background.default,
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.background.paper
|
||||
: theme.palette.background.default,
|
||||
0.7
|
||||
),
|
||||
}}
|
||||
@@ -56,4 +57,4 @@ function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default withTheme(memo(TooltipComponent))
|
||||
export default memo(TooltipComponent)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import Play from '@material-ui/icons/PlayArrow'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import Play from '@mui/icons-material/PlayArrow'
|
||||
import Pause from '@mui/icons-material/PauseCircleFilled'
|
||||
import Clear from '@mui/icons-material/Clear'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { SettingsButton } from './ChartSettings/SettingsButton'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Menu, MenuItem } from '@material-ui/core'
|
||||
import { Menu, MenuItem } from '@mui/material'
|
||||
import { colors as createColors } from './colors'
|
||||
|
||||
function chartParametersForColor(chart: ChartParameters, color?: string) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Menu, MenuItem, Typography } from '@material-ui/core'
|
||||
import { Menu, MenuItem, Typography } from '@mui/material'
|
||||
|
||||
function chartParametersForAction(chart: ChartParameters, action: string) {
|
||||
return {
|
||||
@@ -39,7 +39,12 @@ function InterpolationSettings(props: {
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
return curves.map(curve => (
|
||||
<MenuItem key={curve} onClick={callbacks[curve]} selected={props.chart.interpolation === curve}>
|
||||
<MenuItem
|
||||
key={curve}
|
||||
onClick={callbacks[curve]}
|
||||
selected={props.chart.interpolation === curve}
|
||||
data-menu-item={curve.replace(/_/g, ' ')}
|
||||
>
|
||||
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward'
|
||||
import ArrowUpward from '@mui/icons-material/ArrowUpward'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { MenuItem, Typography, ListItemIcon } from '@material-ui/core'
|
||||
import { MenuItem, Typography, ListItemIcon } from '@mui/material'
|
||||
|
||||
function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartParameters; close: () => void }) {
|
||||
const moveUp = React.useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { Menu, TextField, Typography } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import ChartSettings from '.'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import MoreVertIcon from '@material-ui/icons/Settings'
|
||||
import MoreVertIcon from '@mui/icons-material/Settings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
|
||||
export function SettingsButton(props: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { Button, Menu, TextField, Typography } from '@mui/material'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
yellow,
|
||||
brown,
|
||||
blueGrey,
|
||||
} from '@material-ui/core/colors'
|
||||
} from '@mui/material/colors'
|
||||
|
||||
export function colors() {
|
||||
function colorToInt(color: string): [number, number, number] {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import BarChart from '@material-ui/icons/BarChart'
|
||||
import Clear from '@material-ui/icons/Refresh'
|
||||
import ColorLens from '@material-ui/icons/ColorLens'
|
||||
import BarChart from '@mui/icons-material/BarChart'
|
||||
import Clear from '@mui/icons-material/Refresh'
|
||||
import ColorLens from '@mui/icons-material/ColorLens'
|
||||
import ColorSettings from './ColorSettings'
|
||||
import InterpolationSettings from './InterpolationSettings'
|
||||
import MoveUp from './MoveUp'
|
||||
import MultilineChart from '@material-ui/icons/MultilineChart'
|
||||
import MultilineChart from '@mui/icons-material/MultilineChart'
|
||||
import RangeSettings from './RangeSettings'
|
||||
import React, { memo } from 'react'
|
||||
import Size from './Size'
|
||||
import Sort from '@material-ui/icons/Sort'
|
||||
import Sort from '@mui/icons-material/Sort'
|
||||
import TimeRangeSettings from './TimeRangeSettings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, ListItemIcon, Typography } from '@material-ui/core'
|
||||
import { Menu, MenuItem, ListItemIcon, Typography } from '@mui/material'
|
||||
|
||||
function ChartSettings(props: {
|
||||
open: boolean
|
||||
@@ -65,37 +65,37 @@ function ChartSettings(props: {
|
||||
return (
|
||||
<span>
|
||||
<Menu id="long-menu" anchorEl={props.anchorEl.current} open={props.open} onClose={props.close}>
|
||||
<MenuItem key="range" onClick={toggleRange}>
|
||||
<MenuItem key="range" onClick={toggleRange} data-menu-item="Y-Axis range (Values)">
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Y-Axis range (Values)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange}>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange} data-menu-item="X-Axis range (Time)">
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">X-Axis range (Time)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation}>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation} data-menu-item="Curve interpolation">
|
||||
<ListItemIcon>
|
||||
<MultilineChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Curve interpolation</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="size" onClick={toggleSize}>
|
||||
<MenuItem key="size" onClick={toggleSize} data-menu-item="Size">
|
||||
<ListItemIcon>
|
||||
<Sort />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Size</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="color" onClick={toggleColor}>
|
||||
<MenuItem key="color" onClick={toggleColor} data-menu-item="Color">
|
||||
<ListItemIcon>
|
||||
<ColorLens />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Color</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction}>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction} data-menu-item="Clear data">
|
||||
<ListItemIcon>
|
||||
<Clear />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { Typography, Theme, withStyles } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
|
||||
const { classes, parameters } = props
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChartActions } from './ChartActions'
|
||||
import { chartActions } from '../../actions'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Paper } from '@material-ui/core'
|
||||
import { Paper } from '@mui/material'
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
class ClearableMessageBuffer extends q.RingBuffer<q.Message> {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ShowChart from '@material-ui/icons/ShowChart'
|
||||
import ShowChart from '@mui/icons-material/ShowChart'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../actions'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { ChartWithTreeNode } from './ChartWithTreeNode'
|
||||
import { connect } from 'react-redux'
|
||||
import { Grid, Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { Grid, Typography } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { List } from 'immutable'
|
||||
const { TransitionGroup, CSSTransition } = require('react-transition-group/esm')
|
||||
|
||||
@@ -126,4 +128,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ChartPanel))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ChartPanel) as any)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useCallback, memo } from 'react'
|
||||
import { ConfirmationRequest } from '../reducers/Global'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@material-ui/core'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'
|
||||
import { KeyCodes } from '../utils/KeyCodes'
|
||||
|
||||
function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationRequest> }) {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useCallback, memo } from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import Add from '@mui/icons-material/Add'
|
||||
import Lock from '@mui/icons-material/Lock'
|
||||
import Undo from '@mui/icons-material/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Button, Grid, TextField, Tooltip } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Button, Grid, TextField, Tooltip } from '@mui/material'
|
||||
import { QosSelect } from '../QosSelect'
|
||||
import { QoS } from '../../../../backend/src/DataSource/MqttSource'
|
||||
import Subscriptions from './Subscriptions'
|
||||
const SubscriptionsAny = Subscriptions as any
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -63,12 +65,13 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
color="secondary"
|
||||
onClick={() => props.managerActions.addSubscription({ topic, qos }, props.connection.id)}
|
||||
variant="contained"
|
||||
data-testid="add-subscription-button"
|
||||
>
|
||||
<Add /> Add
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} style={{ padding: 0 }}>
|
||||
<Subscriptions connection={props.connection} />
|
||||
<SubscriptionsAny connection={props.connection} />
|
||||
</Grid>
|
||||
<Grid item={true} xs={7} className={classes.gridPadding}>
|
||||
<TextField
|
||||
@@ -97,6 +100,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="back-button"
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
@@ -129,4 +133,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings) as any)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import Lock from '@mui/icons-material/Lock'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
|
||||
import { Button, Theme, Tooltip, Typography } from '@mui/material'
|
||||
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { rendererRpc } from '../../../../events'
|
||||
import { RpcEvents } from '../../../../events/EventsV2'
|
||||
|
||||
@@ -129,7 +129,7 @@ const styles = (theme: Theme) => ({
|
||||
overflow: 'hidden' as 'hidden',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
color: theme.palette.text.hint,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
@@ -137,4 +137,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(BrowserCertificateFileSelection))
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(BrowserCertificateFileSelection) as any)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import Lock from '@mui/icons-material/Lock'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
|
||||
import { Button, Theme, Tooltip, Typography } from '@mui/material'
|
||||
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
function CertificateFileSelection(props: {
|
||||
certificateType: CertificateTypes
|
||||
@@ -71,7 +71,7 @@ const styles = (theme: Theme) => ({
|
||||
overflow: 'hidden' as 'hidden',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
color: theme.palette.text.hint,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
@@ -79,4 +79,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(CertificateFileSelection))
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(CertificateFileSelection) as any)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import CertificateFileSelection from './CertificateFileSelection'
|
||||
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import Undo from '@mui/icons-material/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Grid } from '@material-ui/core'
|
||||
import { Button, Grid } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
// Check if we're in browser mode
|
||||
const isBrowserMode =
|
||||
typeof window !== 'undefined' && (typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
|
||||
const CertSelector = isBrowserMode ? BrowserCertificateFileSelection : CertificateFileSelection
|
||||
// Use browser or desktop file selection based on mode
|
||||
const CertSelector: any = isBrowserMode ? BrowserCertificateFileSelection : CertificateFileSelection
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -110,4 +110,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Certificates))
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Certificates) as any)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
|
||||
import PowerSettingsNew from '@mui/icons-material/PowerSettingsNew'
|
||||
import React from 'react'
|
||||
import { Button } from '@material-ui/core'
|
||||
import { Button } from '@mui/material'
|
||||
|
||||
function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) {
|
||||
const { classes, toggle, connecting } = props
|
||||
|
||||
if (connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="abort-button">
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
</Button>
|
||||
@@ -16,7 +16,7 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="connect-button">
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import ConnectButton from './ConnectButton'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Save from '@material-ui/icons/Save'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Settings from '@material-ui/icons/Settings'
|
||||
import Visibility from '@material-ui/icons/Visibility'
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff'
|
||||
import Save from '@mui/icons-material/Save'
|
||||
import Delete from '@mui/icons-material/Delete'
|
||||
import Settings from '@mui/icons-material/Settings'
|
||||
import Visibility from '@mui/icons-material/Visibility'
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { ToggleSwitch } from './ToggleSwitch'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
TextField,
|
||||
} from '@material-ui/core'
|
||||
} from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -235,6 +236,7 @@ function ConnectionSettings(props: Props) {
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="advanced-button"
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
@@ -285,4 +287,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSettings) as any)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import ConnectionSettings from './ConnectionSettings'
|
||||
const ConnectionSettingsAny = ConnectionSettings as any
|
||||
import ProfileList from './ProfileList'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Modal, Paper, Toolbar, Typography, Collapse } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Modal, Paper, Toolbar, Typography, Collapse } from '@mui/material'
|
||||
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
|
||||
const AdvancedConnectionSettingsAny = AdvancedConnectionSettings as any
|
||||
import Certificates from './Certificates'
|
||||
const CertificatesAny = Certificates as any
|
||||
|
||||
interface Props {
|
||||
actions: any
|
||||
@@ -34,13 +38,13 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
|
||||
return (
|
||||
<div>
|
||||
<Collapse in={!showAdvancedSettings && !showCertificateSettings}>
|
||||
<ConnectionSettings connection={connection} />
|
||||
<ConnectionSettingsAny connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showAdvancedSettings && !showCertificateSettings}>
|
||||
<AdvancedConnectionSettings connection={connection} />
|
||||
<AdvancedConnectionSettingsAny connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showCertificateSettings}>
|
||||
<Certificates connection={connection} />
|
||||
<CertificatesAny connection={connection} />
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
@@ -111,7 +115,7 @@ const styles = (theme: Theme) => ({
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.9em',
|
||||
marginLeft: theme.spacing(4),
|
||||
},
|
||||
@@ -134,4 +138,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup) as any)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import { Fab } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import Add from '@mui/icons-material/Add'
|
||||
import { Fab } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
addButton: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { ListItem, Typography } from '@material-ui/core'
|
||||
import { ListItem, Typography, Box } from '@mui/material'
|
||||
import { DragIndicator } from '@mui/icons-material'
|
||||
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connectionActions, connectionManagerActions } from '../../../actions'
|
||||
|
||||
@@ -14,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) => {
|
||||
@@ -24,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>
|
||||
)
|
||||
}
|
||||
@@ -62,9 +95,28 @@ export const connectionItemStyle = (theme: Theme) => ({
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
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))
|
||||
export default connect(null, mapDispatchToProps)(withStyles(connectionItemStyle)(ConnectionItem) as any)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ConnectionItem from './ConnectionItem'
|
||||
import React from 'react'
|
||||
const ConnectionItemAny = ConnectionItem as any
|
||||
import React, { useState } from 'react'
|
||||
import { AddButton } from './AddButton'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
@@ -7,8 +8,9 @@ import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../../actions'
|
||||
import { ConnectionOptions } from '../../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../../utils/KeyCodes'
|
||||
import { List } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { List } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
|
||||
|
||||
interface Props {
|
||||
@@ -20,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) {
|
||||
@@ -38,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} />
|
||||
@@ -48,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 => (
|
||||
<ConnectionItem 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>
|
||||
)
|
||||
@@ -77,4 +122,4 @@ const mapStateToProps = (state: AppState) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList) as any)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Delete from '@mui/icons-material/Delete'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import {
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
TableBody,
|
||||
Paper,
|
||||
Theme,
|
||||
} from '@material-ui/core'
|
||||
} from '@mui/material'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
function Subscriptions(props: {
|
||||
@@ -87,4 +87,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Subscriptions))
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Subscriptions) as any)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { FormControlLabel, Switch } from '@material-ui/core'
|
||||
import { FormControlLabel, Switch } from '@mui/material'
|
||||
|
||||
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
|
||||
const { classes, value, toggle, label } = props
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
interface Props {
|
||||
keyboardKey: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
const cursor = require('./cursor.png')
|
||||
|
||||
interface State {
|
||||
@@ -74,7 +75,7 @@ const style = (theme: Theme) => ({
|
||||
height: '32px',
|
||||
position: 'fixed' as 'fixed',
|
||||
zIndex: 1000000,
|
||||
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
|
||||
filter: theme.palette.mode === 'light' ? undefined : 'invert(100%)',
|
||||
pointerEvents: 'none' as 'none',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import Key from './Key'
|
||||
|
||||
interface State {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import PersistentStorage from '../utils/PersistentStorage'
|
||||
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
|
||||
import Warning from '@material-ui/icons/Warning'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Button, Modal, Paper, Toolbar, Typography } from '@material-ui/core'
|
||||
import SentimentDissatisfied from '@mui/icons-material/SentimentDissatisfied'
|
||||
import Warning from '@mui/icons-material/Warning'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Button, Modal, Paper, Toolbar, Typography } from '@mui/material'
|
||||
|
||||
interface State {
|
||||
error?: Error
|
||||
@@ -11,6 +12,7 @@ interface State {
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
@@ -23,12 +25,12 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
private restart = () => {
|
||||
window.location = window.location
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
private clearStorage = () => {
|
||||
PersistentStorage.clear()
|
||||
window.location = window.location
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: any) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import ChartPanel from '../ChartPanel'
|
||||
import ReactSplitPane from 'react-split-pane'
|
||||
import ReactSplitPaneImport from 'react-split-pane'
|
||||
import Tree from '../Tree'
|
||||
import { AppState } from '../../reducers'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { List } from 'immutable'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import ReactResizeDetector from 'react-resize-detector'
|
||||
import { useResizeDetector } from 'react-resize-detector'
|
||||
|
||||
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
|
||||
const ReactSplitPane = ReactSplitPaneImport as any
|
||||
|
||||
interface Props {
|
||||
heightProperty: any
|
||||
@@ -21,11 +24,23 @@ function ContentView(props: Props) {
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
|
||||
const [detectedHeight, setDetectedHeight] = React.useState(0)
|
||||
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
|
||||
const detectSize = React.useCallback((width, newHeight) => {
|
||||
|
||||
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
|
||||
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (resizeHeight) setDetectedHeight(resizeHeight)
|
||||
}, [resizeHeight])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (resizeWidth) setDetectedSidebarWidth(resizeWidth)
|
||||
}, [resizeWidth])
|
||||
|
||||
const detectSize = React.useCallback((width: any, newHeight: any) => {
|
||||
setDetectedHeight(newHeight)
|
||||
}, [])
|
||||
|
||||
const detectSidebarSize = React.useCallback(width => {
|
||||
const detectSidebarSize = React.useCallback((width: any) => {
|
||||
setDetectedSidebarWidth(width)
|
||||
}, [])
|
||||
|
||||
@@ -63,7 +78,7 @@ function ContentView(props: Props) {
|
||||
split="vertical"
|
||||
minSize={0}
|
||||
size={sidebarWidth}
|
||||
onChange={setSidebarWidth}
|
||||
onChange={(size: number) => setSidebarWidth(size)}
|
||||
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
|
||||
allowResize={true}
|
||||
style={{ height: '100%' }}
|
||||
@@ -80,20 +95,18 @@ function ContentView(props: Props) {
|
||||
style={{ height: 'calc(100vh - 64px)' }}
|
||||
pane1Style={{ maxHeight: '100%' }}
|
||||
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
|
||||
onChange={setHeight}
|
||||
onChange={(size: number) => setHeight(size)}
|
||||
onDragFinished={closeDrawerCompletelyIfItSitsOnTheEdge}
|
||||
>
|
||||
<Tree />
|
||||
{/** Passing height constraints via flex options down */}
|
||||
<div style={{ flex: 1, display: 'flex', height: '100%', width: '100%' }}>
|
||||
<div ref={heightRef} style={{ flex: 1, display: 'flex', height: '100%', width: '100%' }}>
|
||||
{/** Resize detector must not be in the scroll zone, it needs to detect actual available size */}
|
||||
<ReactResizeDetector handleHeight={true} onResize={detectSize} />
|
||||
<ChartPanel />
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
<div style={{ height: '100%' }}>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectSidebarSize} />
|
||||
<div ref={widthRef} style={{ height: '100%' }}>
|
||||
<div
|
||||
className={props.paneDefaults}
|
||||
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Snackbar, SnackbarContent } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { green, red } from '@material-ui/core/colors'
|
||||
import { Snackbar, SnackbarContent } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { green, red } from '@mui/material/colors'
|
||||
|
||||
interface Props {
|
||||
message?: string
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Resume from '@material-ui/icons/PlayArrow'
|
||||
import Pause from '@mui/icons-material/PauseCircleFilled'
|
||||
import Resume from '@mui/icons-material/PlayArrow'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { treeActions } from '../../actions'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
icon: {
|
||||
@@ -102,4 +103,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(PauseButton))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(PauseButton) as any)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useCallback, useState, useRef } from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Search from '@material-ui/icons/Search'
|
||||
import Search from '@mui/icons-material/Search'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { InputBase } from '@material-ui/core'
|
||||
import { InputBase } from '@mui/material'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { fade, Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { alpha as fade, Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
|
||||
@@ -142,4 +143,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(SearchBar))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(SearchBar) as any)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import CloudOff from '@material-ui/icons/CloudOff'
|
||||
import CloudOff from '@mui/icons-material/CloudOff'
|
||||
import Logout from '@mui/icons-material/Logout'
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import Menu from '@material-ui/icons/Menu'
|
||||
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import PauseButton from './PauseButton'
|
||||
import SearchBar from './SearchBar'
|
||||
import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core'
|
||||
import { AppBar, Button, IconButton, Toolbar, Typography } from '@mui/material'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, globalActions, settingsActions } from '../../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
title: {
|
||||
@@ -33,6 +37,9 @@ const styles = (theme: Theme) => ({
|
||||
disconnect: {
|
||||
margin: 'auto 8px auto auto',
|
||||
},
|
||||
logout: {
|
||||
margin: 'auto 0 auto 8px',
|
||||
},
|
||||
disconnectLabel: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
@@ -54,6 +61,22 @@ class TitleBar extends React.PureComponent<Props, {}> {
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private handleLogout = async () => {
|
||||
// Disconnect first
|
||||
this.props.actions.connection.disconnect()
|
||||
|
||||
// Clear credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Reload page to reset all state and show login dialog
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { actions, classes } = this.props
|
||||
|
||||
@@ -75,12 +98,22 @@ class TitleBar extends React.PureComponent<Props, {}> {
|
||||
<PauseButton />
|
||||
<Button
|
||||
className={classes.disconnect}
|
||||
classes={{ label: classes.disconnectLabel }}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
onClick={actions.connection.disconnect}
|
||||
data-testid="disconnect-button"
|
||||
>
|
||||
Disconnect <CloudOff className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
<ConnectionHealthIndicator withBackground={true} />
|
||||
{isBrowserMode && (
|
||||
<Button
|
||||
className={classes.logout}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
onClick={this.handleLogout}
|
||||
>
|
||||
Logout <Logout className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
)}
|
||||
<ConnectionHealthIndicatorAny withBackground={true} />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
|
||||
@@ -1,57 +1,106 @@
|
||||
import * as React from 'react'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Typography } from '@material-ui/core'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Typography } from '@mui/material'
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean
|
||||
onLogin: (username: string, password: string) => void
|
||||
error?: string
|
||||
waitTimeSeconds?: number
|
||||
}
|
||||
|
||||
export function LoginDialog(props: LoginDialogProps) {
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [countdown, setCountdown] = React.useState<number | undefined>(props.waitTimeSeconds)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Update countdown when waitTimeSeconds prop changes
|
||||
React.useEffect(() => {
|
||||
setCountdown(props.waitTimeSeconds)
|
||||
}, [props.waitTimeSeconds])
|
||||
|
||||
// Countdown timer
|
||||
React.useEffect(() => {
|
||||
if (countdown === undefined || countdown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev === undefined || prev <= 1) {
|
||||
return undefined
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleLogin = () => {
|
||||
if (countdown !== undefined && countdown > 0) {
|
||||
// Don't allow login during countdown
|
||||
return
|
||||
}
|
||||
if (!username || !password) {
|
||||
// Don't allow empty credentials
|
||||
return
|
||||
}
|
||||
props.onLogin(username, password)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = countdown !== undefined && countdown > 0
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} disableEscapeKeyDown disableBackdropClick>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogTitle>Login to MQTT Explorer</DialogTitle>
|
||||
<DialogContent>
|
||||
{props.error && (
|
||||
<Typography color="error" style={{ marginBottom: 16 }}>
|
||||
{props.error}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="submit" color="primary" variant="contained">
|
||||
Login
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
<Dialog open={props.open} disableEscapeKeyDown onClose={(event, reason) => { if (reason !== 'backdropClick') { /* Allow closing only via escape if needed */ } }}>
|
||||
<DialogTitle>Login to MQTT Explorer</DialogTitle>
|
||||
<DialogContent>
|
||||
{props.error && (
|
||||
<Typography color="error" style={{ marginBottom: 16 }}>
|
||||
{props.error}
|
||||
</Typography>
|
||||
)}
|
||||
{countdown !== undefined && countdown > 0 && (
|
||||
<Typography color="warning" style={{ marginBottom: 16, fontWeight: 'bold' }}>
|
||||
Please wait {countdown} seconds before trying again...
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
data-testid="username-input"
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
data-testid="password-input"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleLogin} color="primary" variant="contained" disabled={isDisabled}>
|
||||
{isDisabled ? `Wait ${countdown}s` : 'Login'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { TextField, MenuItem, Tooltip } from '@material-ui/core'
|
||||
import { TextField, MenuItem, Tooltip } from '@mui/material'
|
||||
import { QoS } from '../../../backend/src/DataSource/MqttSource'
|
||||
|
||||
export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void; label?: string }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import { InputLabel, Switch, Theme, Tooltip } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { InputLabel, Switch, Theme, Tooltip } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any }) {
|
||||
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any; 'data-testid'?: string }) {
|
||||
const { tooltip, value, action, title, classes } = props
|
||||
|
||||
const clickHandler = (e: React.MouseEvent) => {
|
||||
@@ -20,7 +20,13 @@ function BooleanSwitch(props: { title: string; value: boolean; tooltip: string;
|
||||
</InputLabel>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<Switch name={`toggle-${sha1(title)}`} checked={value} onChange={action} color="primary" />
|
||||
<Switch
|
||||
name={`toggle-${sha1(title)}`}
|
||||
checked={value}
|
||||
onChange={action}
|
||||
color="primary"
|
||||
data-testid={props['data-testid']}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,9 +3,10 @@ import React, { useMemo } from 'react'
|
||||
import { AppState } from '../../reducers'
|
||||
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
|
||||
import { connect } from 'react-redux'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
import { useUpdateComponentWhenNodeUpdates } from '../helper/useUpdateComponentWhenNodeUpdates'
|
||||
const abbreviate = require('number-abbreviate')
|
||||
@@ -19,7 +20,7 @@ const styles = (theme: Theme) => ({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '224px',
|
||||
backgroundColor: theme.palette.type === 'dark' ? 'rebeccapurple' : '#ebebeb',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rebeccapurple' : '#ebebeb',
|
||||
marginBottom: 0,
|
||||
padding: '8px',
|
||||
},
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import BooleanSwitch from './BooleanSwitch'
|
||||
import BrokerStatistics from './BrokerStatistics'
|
||||
import ChevronRight from '@material-ui/icons/ChevronRight'
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight'
|
||||
import TimeLocale from './TimeLocale'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions } from '../../actions'
|
||||
import { shell } from 'electron'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicOrder } from '../../reducers/Settings'
|
||||
|
||||
import {
|
||||
@@ -19,9 +20,10 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from '@material-ui/core'
|
||||
} from '@mui/material'
|
||||
|
||||
export const autoExpandLimitSet = [
|
||||
{
|
||||
@@ -70,7 +72,7 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
author: {
|
||||
margin: 'auto 8px 8px auto',
|
||||
color: theme.palette.text.hint,
|
||||
color: theme.palette.text.secondary,
|
||||
cursor: 'pointer' as 'pointer',
|
||||
},
|
||||
})
|
||||
@@ -136,6 +138,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
tooltip="Enable dark theme"
|
||||
value={theme === 'dark'}
|
||||
action={actions.settings.toggleTheme}
|
||||
data-testid="dark-mode-toggle"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -167,7 +170,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeAutoExpand = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
private onChangeAutoExpand = (e: SelectChangeEvent<number>) => {
|
||||
this.props.actions.settings.setAutoExpandLimit(parseInt(String(e.target.value), 10))
|
||||
}
|
||||
|
||||
@@ -199,7 +202,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeSorting = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
private onChangeSorting = (e: SelectChangeEvent<TopicOrder>) => {
|
||||
this.props.actions.settings.setTopicOrder(e.target.value as TopicOrder)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import DateFormatter from '../helper/DateFormatter'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { Input, InputLabel, MenuItem, Select, Theme } from '@material-ui/core'
|
||||
import { Input, InputLabel, MenuItem, Select, Theme } from '@mui/material'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
function importAll(r: any) {
|
||||
r.keys().forEach(r)
|
||||
@@ -38,7 +38,7 @@ function TimeLocaleSettings(props: Props) {
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
function updateLocale(e: React.ChangeEvent<{ value: unknown }>) {
|
||||
function updateLocale(e: any) {
|
||||
const locale = e.target.value ? String(e.target.value) : ''
|
||||
actions.settings.setTimeLocale(locale)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ShowChart from '@material-ui/icons/ShowChart'
|
||||
import ShowChart from '@mui/icons-material/ShowChart'
|
||||
import TopicPlot from '../../TopicPlot'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { connect } from 'react-redux'
|
||||
import { Fade, Paper, Popper, Tooltip } from '@material-ui/core'
|
||||
import { Fade, Paper, Popper, Tooltip } from '@mui/material'
|
||||
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { Theme } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
interface Props {
|
||||
changes: Array<Diff.Change>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as diff from 'diff'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import Add from '@mui/icons-material/Add'
|
||||
import ChartPreview from './ChartPreview'
|
||||
import Remove from '@material-ui/icons/Remove'
|
||||
import Remove from '@mui/icons-material/Remove'
|
||||
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
|
||||
import { lineChangeStyle, trimNewlineRight } from './util'
|
||||
import { Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { Theme } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
interface Props {
|
||||
changes: Array<diff.Change>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { isPlottable, lineChangeStyle, trimNewlineRight } from './util'
|
||||
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
|
||||
import { selectTextWithCtrlA } from '../../../utils/handleTextSelectWithCtrlA'
|
||||
import { style } from './style'
|
||||
import { withStyles, Typography } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import 'prismjs/components/prism-json'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
|
||||
import { Theme } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material'
|
||||
|
||||
export const style = (theme: Theme) => {
|
||||
const codeBlockColors = theme.palette.type === 'light' ? CodeBlockColors : CodeBlockColorsBraceMonokai
|
||||
const codeBlockColors = theme.palette.mode === 'light' ? CodeBlockColors : CodeBlockColorsBraceMonokai
|
||||
const codeBaseStyle = {
|
||||
font: "12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace",
|
||||
display: 'inline-grid' as 'inline-grid',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useState, useEffect, memo } from 'react'
|
||||
import { Badge, Typography } from '@material-ui/core'
|
||||
import { Badge, Typography } from '@mui/material'
|
||||
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
|
||||
import { Theme, withStyles, emphasize } from '@material-ui/core/styles'
|
||||
import { Theme, emphasize } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
interface HistoryItem {
|
||||
key: string
|
||||
@@ -87,6 +88,7 @@ function HistoryDrawer(props: Props) {
|
||||
invisible={!visible}
|
||||
badgeContent={props.items.length}
|
||||
color="primary"
|
||||
data-testid="message-history"
|
||||
>
|
||||
{expanded ? '▼ History' : '▶ History'}
|
||||
</Badge>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Message } from '../../../../backend/src/Model'
|
||||
import { Tooltip } from '@material-ui/core'
|
||||
import { Tooltip } from '@mui/material'
|
||||
|
||||
export const MessageId = memo(function MessageId(props: { message: Message; addComma?: boolean }) {
|
||||
const { message, addComma } = props
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode<TopicViewModel>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore'
|
||||
import { ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, Typography, Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore'
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Typography, Theme } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
summary: { minHeight: '0' },
|
||||
@@ -19,14 +19,14 @@ const Panel = (props: {
|
||||
detailsHidden?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<ExpansionPanel defaultExpanded={true} disabled={props.disabled}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
|
||||
<Accordion defaultExpanded={true} disabled={props.disabled}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
|
||||
<Typography className={props.classes.heading}>{props.children[0]}</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
</AccordionSummary>
|
||||
{props.detailsHidden ? null : (
|
||||
<ExpansionPanelDetails className={props.classes.detail}>{props.children[1]}</ExpansionPanelDetails>
|
||||
<AccordionDetails className={props.classes.detail}>{props.children[1]}</AccordionDetails>
|
||||
)}
|
||||
</ExpansionPanel>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { default as AceEditor } from 'react-ace'
|
||||
import { Theme, withTheme } from '@material-ui/core'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import 'ace-builds'
|
||||
import 'ace-builds/webpack-resolver'
|
||||
import 'ace-builds/src-noconflict/mode-json'
|
||||
@@ -13,11 +13,11 @@ import 'react-ace'
|
||||
|
||||
function Editor(props: {
|
||||
editorMode: string
|
||||
theme: Theme
|
||||
value: string | undefined
|
||||
onChange: (value: string) => void
|
||||
editorRef: React.Ref<AceEditor>
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const editorOptions = {
|
||||
showLineNumbers: false,
|
||||
tabSize: 2,
|
||||
@@ -28,7 +28,7 @@ function Editor(props: {
|
||||
ref={props.editorRef}
|
||||
style={{}}
|
||||
mode={props.editorMode}
|
||||
theme={props.theme.palette.type === 'dark' ? 'monokai' : 'dawn'}
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'dawn'}
|
||||
name="UNIQUE_ID_OF_DIV"
|
||||
width="100%"
|
||||
height="200px"
|
||||
@@ -45,4 +45,4 @@ function Editor(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export default withTheme(Editor)
|
||||
export default Editor
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { FormControlLabel, Radio, RadioGroup } from '@material-ui/core'
|
||||
import { FormControlLabel, Radio, RadioGroup } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Editor from './Editor'
|
||||
import { AttachFileOutlined, FormatAlignLeft } from '@material-ui/icons'
|
||||
import { AttachFileOutlined, FormatAlignLeft } from '@mui/icons-material'
|
||||
import Message from './Model/Message'
|
||||
import Navigation from '@material-ui/icons/Navigation'
|
||||
import Navigation from '@mui/icons-material/Navigation'
|
||||
import PublishHistory from './PublishHistory'
|
||||
import React, { useCallback, useMemo, useState, useRef, memo } from 'react'
|
||||
import RetainSwitch from './RetainSwitch'
|
||||
import TopicInput from './TopicInput'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Fab, Theme, Tooltip, withTheme } from '@material-ui/core'
|
||||
import { Button, Fab, Tooltip } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { EditorModeSelect } from './EditorModeSelect'
|
||||
import { globalActions, publishActions } from '../../../actions'
|
||||
@@ -23,7 +23,6 @@ interface Props {
|
||||
globalActions: typeof globalActions
|
||||
retain: boolean
|
||||
editorMode: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
function useHistory(): [Array<Message>, (topic: string, payload?: string) => void] {
|
||||
@@ -221,4 +220,4 @@ const mapStateToProps = (state: AppState) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withTheme(Publish))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Publish)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import QosSelect from './QosPublishOption'
|
||||
import React from 'react'
|
||||
import { Checkbox, FormControlLabel, Tooltip } from '@material-ui/core'
|
||||
import { Checkbox, FormControlLabel, Tooltip } from '@mui/material'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ClearAdornment from '../../helper/ClearAdornment'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { FormControl, Input, InputLabel } from '@material-ui/core'
|
||||
import { FormControl, Input, InputLabel } from '@mui/material'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
|
||||
@@ -2,12 +2,14 @@ import * as q from '../../../../backend/src/Model'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import NodeStats from './NodeStats'
|
||||
import ValuePanel from './ValueRenderer/ValuePanel'
|
||||
const ValuePanelAny = ValuePanel as any
|
||||
import { AppState } from '../../reducers'
|
||||
import { ExpansionPanelDetails } from '@material-ui/core'
|
||||
import { AccordionDetails } from '@mui/material'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { settingsActions, sidebarActions } from '../../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import TopicPanel from './TopicPanel/TopicPanel'
|
||||
import Panel from './Panel'
|
||||
@@ -56,16 +58,16 @@ function Sidebar(props: Props) {
|
||||
<div id="Sidebar" className={classes.drawer}>
|
||||
<div>
|
||||
<TopicPanel node={node} />
|
||||
<ValuePanel lastUpdate={node ? node.lastUpdate : 0} />
|
||||
<ValuePanelAny lastUpdate={node ? node.lastUpdate : 0} />
|
||||
<Panel>
|
||||
<span>Publish</span>
|
||||
<Publish connectionId={props.connectionId} />
|
||||
</Panel>
|
||||
<Panel detailsHidden={!node}>
|
||||
<span>Stats</span>
|
||||
<ExpansionPanelDetails className={classes.details}>
|
||||
<AccordionDetails className={classes.details}>
|
||||
<NodeStats node={node} />
|
||||
</ExpansionPanelDetails>
|
||||
</AccordionDetails>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Delete from '@mui/icons-material/Delete'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Badge } from '@material-ui/core'
|
||||
import { Badge } from '@mui/material'
|
||||
|
||||
export const RecursiveTopicDeleteButton = (props: {
|
||||
node?: q.TreeNode<any>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import Button from '@mui/material/Button'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { treeActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -39,8 +40,8 @@ class Topic extends React.PureComponent<Props, {}> {
|
||||
<Button
|
||||
onClick={() => this.props.actions.selectTopic(edge!.target)}
|
||||
size="small"
|
||||
variant={theme.palette.type === 'light' ? 'contained' : undefined}
|
||||
color={theme.palette.type === 'light' ? 'primary' : 'secondary'}
|
||||
variant={theme.palette.mode === 'light' ? 'contained' : undefined}
|
||||
color={theme.palette.mode === 'light' ? 'primary' : 'secondary'}
|
||||
className={this.props.classes.button}
|
||||
key={edge!.hash()}
|
||||
>
|
||||
@@ -66,4 +67,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(withStyles(styles, { withTheme: true })(Topic))
|
||||
export default connect(null, mapDispatchToProps)(withStyles(styles, { withTheme: true })(Topic) as any)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Delete from '@mui/icons-material/Delete'
|
||||
import React from 'react'
|
||||
|
||||
export const TopicDeleteButton = (props: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Copy from '../../helper/Copy'
|
||||
import Panel from '../Panel'
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import Topic from './Topic'
|
||||
const TopicAny = Topic as any
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { RecursiveTopicDeleteButton } from './RecursiveTopicDeleteButton'
|
||||
@@ -31,7 +32,7 @@ const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActi
|
||||
<RecursiveTopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
|
||||
<TopicTypeButton node={node} />
|
||||
</span>
|
||||
<Topic node={node} />
|
||||
<TopicAny node={node} />
|
||||
</Panel>
|
||||
),
|
||||
[node, node?.childTopicCount()]
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
|
||||
import Grow from '@material-ui/core/Grow'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import Popper from '@material-ui/core/Popper'
|
||||
import MenuItem from '@material-ui/core/MenuItem'
|
||||
import MenuList from '@material-ui/core/MenuList'
|
||||
import WarningRounded from '@material-ui/icons/WarningRounded'
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener'
|
||||
import Grow from '@mui/material/Grow'
|
||||
import Button from '@mui/material/Button'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Popper from '@mui/material/Popper'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import MenuList from '@mui/material/MenuList'
|
||||
import WarningRounded from '@mui/icons-material/WarningRounded'
|
||||
import { MessageDecoder, decoders } from '../../../decoders'
|
||||
import { Tooltip } from '@material-ui/core'
|
||||
import { Tooltip } from '@mui/material'
|
||||
|
||||
export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
const { node } = props
|
||||
@@ -46,7 +46,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
[open]
|
||||
)
|
||||
|
||||
const handleClose = useCallback((event: React.MouseEvent<Document, MouseEvent>) => {
|
||||
const handleClose = useCallback((event: any) => {
|
||||
if (anchorEl && anchorEl.contains(event.target as HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import Code from '@material-ui/icons/Code'
|
||||
import Reorder from '@material-ui/icons/Reorder'
|
||||
import ToggleButton from '@material-ui/lab/ToggleButton'
|
||||
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'
|
||||
import Code from '@mui/icons-material/Code'
|
||||
import Reorder from '@mui/icons-material/Reorder'
|
||||
import ToggleButton from '@mui/lab/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/lab/ToggleButtonGroup'
|
||||
import { settingsActions } from '../../../actions'
|
||||
import { Tooltip, withStyles, Theme } from '@material-ui/core'
|
||||
import { Tooltip } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -73,4 +75,4 @@ const mapStateToProps = (state: AppState) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ActionButtons))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ActionButtons) as any)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import Clear from '@mui/icons-material/Clear'
|
||||
import React, { useMemo } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Tooltip } from '@material-ui/core'
|
||||
import { Button, Tooltip } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { sidebarActions } from '../../../actions'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ShowChart from '@material-ui/icons/ShowChart'
|
||||
import ShowChart from '@mui/icons-material/ShowChart'
|
||||
import Copy from '../../helper/Copy'
|
||||
import DateFormatter from '../../helper/DateFormatter'
|
||||
import History from '../HistoryDrawer'
|
||||
|
||||
@@ -9,7 +9,9 @@ import React, { useCallback } from 'react'
|
||||
import ValueRenderer from './ValueRenderer'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { Typography } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { connect } from 'react-redux'
|
||||
import { sidebarActions } from '../../../actions'
|
||||
import DeleteSelectedTopicButton from './DeleteSelectedTopicButton'
|
||||
@@ -143,4 +145,4 @@ const styles = (theme: Theme) => ({
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ValuePanel))
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ValuePanel) as any)
|
||||
|
||||
@@ -4,7 +4,7 @@ import CodeDiff from '../CodeDiff'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
|
||||
import { Fade } from '@material-ui/core'
|
||||
import { Fade } from '@mui/material'
|
||||
import { Decoder } from '../../../../../backend/src/Model/Decoder'
|
||||
import { useDecoder } from '../../hooks/useDecoder'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
|
||||
@@ -3,7 +3,8 @@ import React, { useEffect, useState, useMemo } from 'react'
|
||||
import TreeNode from '.'
|
||||
import { SettingsState } from '../../../reducers/Settings'
|
||||
import { sortedNodes } from '../../../sortedNodes'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
import { treeActions } from '../../../actions'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import React, { memo } from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
import { useDecoder } from '../../hooks/useDecoder'
|
||||
|
||||
@@ -94,7 +95,7 @@ const styles = (theme: Theme) => ({
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
expander: {
|
||||
color: theme.palette.type === 'light' ? '#222' : '#eee',
|
||||
color: theme.palette.mode === 'light' ? '#222' : '#eee',
|
||||
cursor: 'pointer' as 'pointer',
|
||||
paddingRight: theme.spacing(0.25),
|
||||
userSelect: 'none' as 'none',
|
||||
|
||||
@@ -4,7 +4,8 @@ import TreeNodeSubnodes from './TreeNodeSubnodes'
|
||||
import TreeNodeTitle from './TreeNodeTitle'
|
||||
import { SettingsState } from '../../../reducers/Settings'
|
||||
import { styles } from './styles'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
import { treeActions } from '../../../actions'
|
||||
import { useAnimationToIndicateTopicUpdate } from './effects/useAnimationToIndicateTopicUpdate'
|
||||
@@ -35,7 +36,7 @@ function TreeNodeComponent(props: Props) {
|
||||
const deleteTopicCallback = useDeleteKeyCallback(treeNode, actions)
|
||||
useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsedOverride)
|
||||
const animationClass =
|
||||
props.theme.palette.type === 'light' ? props.classes.animationLight : props.classes.animationDark
|
||||
props.theme.palette.mode === 'light' ? props.classes.animationLight : props.classes.animationDark
|
||||
|
||||
useAnimationToIndicateTopicUpdate(
|
||||
nodeRef,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { blueGrey } from '@material-ui/core/colors'
|
||||
import { Theme } from '@material-ui/core/styles'
|
||||
import { blueGrey } from '@mui/material/colors'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
export const styles = (theme: Theme) => {
|
||||
return {
|
||||
@@ -37,7 +37,7 @@ export const styles = (theme: Theme) => {
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: (theme.palette.type === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
|
||||
backgroundColor: (theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
|
||||
},
|
||||
hover: {},
|
||||
title: {
|
||||
@@ -49,7 +49,7 @@ export const styles = (theme: Theme) => {
|
||||
padding: '1px 4px 0 4px',
|
||||
margin: '1px 0px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light,
|
||||
backgroundColor: theme.palette.mode === 'light' ? blueGrey[100] : theme.palette.primary.light,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import compareVersions from 'compare-versions'
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import electron from 'electron'
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import Close from '@material-ui/icons/Close'
|
||||
import CloudDownload from '@material-ui/icons/CloudDownload'
|
||||
import Close from '@mui/icons-material/Close'
|
||||
import CloudDownload from '@mui/icons-material/CloudDownload'
|
||||
import { AppState } from '../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { green } from '@material-ui/core/colors'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { green } from '@mui/material/colors'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { updateNotifierActions } from '../actions'
|
||||
|
||||
import { Button, IconButton, Modal, Paper, Snackbar, SnackbarContent, Typography } from '@material-ui/core'
|
||||
import { Button, IconButton, Modal, Paper, Snackbar, SnackbarContent, Typography } from '@mui/material'
|
||||
import { rendererRpc, getAppVersion } from '../../../events'
|
||||
|
||||
interface Props {
|
||||
@@ -83,7 +84,7 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
|
||||
return res.data as Array<GithubRelease>
|
||||
}
|
||||
|
||||
private onCloseNotification = (event: React.SyntheticEvent<any>, reason: string) => {
|
||||
private onCloseNotification = (event: any, reason: any) => {
|
||||
if (reason === 'clickaway') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import { IconButton, Theme } from '@material-ui/core'
|
||||
import { withTheme } from '@material-ui/core/styles'
|
||||
import Clear from '@mui/icons-material/Clear'
|
||||
import { IconButton, Theme } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
action: any
|
||||
style?: React.CSSProperties
|
||||
variant?: 'primary'
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear button for text input fields
|
||||
*/
|
||||
function ClearAdornment(props: Props) {
|
||||
const theme = useTheme()
|
||||
|
||||
if (!props.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const color = props.variant === 'primary' ? props.theme.palette.primary.contrastText : undefined
|
||||
const color = props.variant === 'primary' ? theme.palette.primary.contrastText : undefined
|
||||
return (
|
||||
<IconButton style={{ ...props.style, padding: '1px' }} onClick={props.action}>
|
||||
<Clear style={{ color, fontSize: '16px' }} />
|
||||
@@ -27,4 +28,4 @@ function ClearAdornment(props: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
export default withTheme(ClearAdornment)
|
||||
export default ClearAdornment
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as React from 'react'
|
||||
import DeviceHubOutlined from '@material-ui/icons/DeviceHubOutlined'
|
||||
import DeviceHubOutlined from '@mui/icons-material/DeviceHubOutlined'
|
||||
import { AppState } from '../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
import { ConnectionHealth } from '../../reducers/Connection'
|
||||
import { green, orange, red } from '@material-ui/core/colors'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { Tooltip } from '@material-ui/core'
|
||||
import { green, orange, red } from '@mui/material/colors'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Tooltip } from '@mui/material'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
offline: {
|
||||
@@ -65,4 +66,4 @@ const mapStateToProps = (state: AppState) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(withStyles(styles)(ConnectionHealthIndicator))
|
||||
export default connect(mapStateToProps)(withStyles(styles)(ConnectionHealthIndicator) as any)
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import Check from '@material-ui/icons/Check'
|
||||
import Check from '@mui/icons-material/Check'
|
||||
import CustomIconButton from './CustomIconButton'
|
||||
import FileCopy from '@material-ui/icons/FileCopy'
|
||||
import FileCopy from '@mui/icons-material/FileCopy'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions } from '../../actions'
|
||||
|
||||
const copy = require('copy-text-to-clipboard')
|
||||
// Fallback for older browsers or when clipboard API is not available
|
||||
const copyTextFallback = require('copy-text-to-clipboard')
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
// Try modern Clipboard API first (works in browser with HTTPS)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed, using fallback:', error)
|
||||
}
|
||||
|
||||
// Fallback to copy-text-to-clipboard library
|
||||
return copyTextFallback(text)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
@@ -26,15 +42,24 @@ class Copy extends React.PureComponent<Props, State> {
|
||||
this.state = { didCopy: false }
|
||||
}
|
||||
|
||||
private handleClick = (event: React.MouseEvent) => {
|
||||
private handleClick = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
copy(this.props.value ?? this.props.getValue?.())
|
||||
this.props.actions.global.showNotification('Copied to clipboard')
|
||||
this.setState({ didCopy: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ didCopy: false })
|
||||
}, 1500)
|
||||
const text = this.props.value ?? this.props.getValue?.()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const success = await copyToClipboard(text)
|
||||
if (success) {
|
||||
this.props.actions.global.showNotification('Copied to clipboard')
|
||||
this.setState({ didCopy: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ didCopy: false })
|
||||
}, 1500)
|
||||
} else {
|
||||
this.props.actions.global.showNotification('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
@@ -45,7 +70,7 @@ class Copy extends React.PureComponent<Props, State> {
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard">
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard" data-testid="copy-button">
|
||||
<div style={{ marginTop: '2px' }}>{icon}</div>
|
||||
</CustomIconButton>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user