Compare commits

..
148 changed files with 2794 additions and 13913 deletions
-64
View File
@@ -1,64 +0,0 @@
# Git
.git
.gitignore
.github
# Dependencies
node_modules
app/node_modules
backend/node_modules
# Build artifacts
build
dist
app/dist
# Testing
coverage
.nyc_output
test-screenshot-*.png
ui-test.mp4
ui-test.gif
# Development
.vscode
.devcontainer
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE
*.swp
*.swo
*~
.idea
# Documentation
*.md
!Readme.md
LICENSE.md
# CI/CD files
.releaserc
appveyor.yml
# Misc
res
scripts
docker
icon.xcf
greenkeeper.json
prettier.config.js
.prettierignore
.eslintrc.json
.cspell.json
tslint.json
mcp.json
# Data directory (will be created in container)
data
-8
View File
@@ -1,8 +0,0 @@
node_modules
build
dist
app/build
*.js
!scripts/*.js
!*.config.js
!*.config.mjs
+14 -315
View File
@@ -1,318 +1,17 @@
{
"root": true,
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
}
},
"extends": [
"airbnb-base",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript"
],
"plugins": [
"@typescript-eslint",
"import"
],
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
},
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
},
"rules": {
"@typescript-eslint/semi": ["error", "never"],
"semi": "off",
"max-len": "warn",
"@typescript-eslint/explicit-member-accessibility": "warn",
"no-else-return": "warn",
"indent": "off",
"@typescript-eslint/indent": "warn",
"import/prefer-default-export": "warn",
"@typescript-eslint/naming-convention": "warn",
"@typescript-eslint/comma-dangle": "warn",
"comma-dangle": "off",
"arrow-parens": "warn",
"import/no-extraneous-dependencies": ["warn", {
"devDependencies": ["**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**", "scripts/**", "src/spec/**"],
"optionalDependencies": true,
"peerDependencies": false
}],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unused-expressions": ["warn", {
"allowShortCircuit": true,
"allowTernary": true
}],
"object-shorthand": "warn",
"prefer-template": "warn",
"no-plusplus": "warn",
"class-methods-use-this": "warn",
"consistent-return": "warn",
"@typescript-eslint/lines-between-class-members": "warn",
"import/extensions": "warn",
"no-shadow": "off",
"@typescript-eslint/no-shadow": "warn",
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"import/no-cycle": "warn",
"import/no-relative-packages": "warn",
"operator-linebreak": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"prefer-destructuring": "warn",
"arrow-body-style": "warn",
"import/order": "warn",
"object-curly-newline": "warn",
"import/no-named-default": "warn",
"no-console": "warn",
"func-names": "warn",
"no-await-in-loop": "warn",
"no-restricted-syntax": "warn",
"no-continue": "warn",
"no-promise-executor-return": "warn",
"no-eval": "error",
"@typescript-eslint/default-param-last": "warn",
"radix": "warn",
"no-param-reassign": "warn",
"no-underscore-dangle": "warn",
"no-restricted-globals": "warn",
"@typescript-eslint/no-useless-constructor": "warn",
"@typescript-eslint/ban-types": "warn",
"global-require": "warn",
"@typescript-eslint/no-var-requires": "warn",
"eqeqeq": ["warn", "always", {"null": "ignore"}],
"no-var": "warn",
"prefer-const": "warn",
"no-unused-expressions": "off",
"curly": "warn",
"no-duplicate-imports": "warn",
"react-hooks/rules-of-hooks": "warn",
"@typescript-eslint/prefer-as-const": "warn",
"prefer-arrow-callback": "warn",
"react/jsx-boolean-value": "warn",
"react/jsx-one-expression-per-line": "warn",
"react/function-component-definition": "warn",
"react/no-unescaped-entities": "warn",
"no-trailing-spaces": "warn",
"padded-blocks": "warn",
"brace-style": "off",
"@typescript-eslint/brace-style": "warn",
"object-curly-spacing": "warn",
"space-before-function-paren": "off",
"@typescript-eslint/space-before-function-paren": "warn",
"keyword-spacing": "warn",
"space-infix-ops": "off",
"@typescript-eslint/space-infix-ops": "warn",
"comma-spacing": "off",
"@typescript-eslint/comma-spacing": "warn",
"quotes": "off",
"@typescript-eslint/quotes": "warn",
"linebreak-style": "warn"
},
"overrides": [
{
"files": ["src/**/*.ts", "scripts/**/*.ts", "events/**/*.ts"],
"parserOptions": {
"project": "./tsconfig.json"
},
"extends": [
"airbnb-base",
"airbnb-typescript/base"
],
"rules": {
"@typescript-eslint/semi": ["error", "never"],
"semi": "off",
"max-len": "warn",
"@typescript-eslint/indent": "warn",
"@typescript-eslint/no-throw-literal": "warn",
"arrow-parens": "warn",
"@typescript-eslint/comma-dangle": "warn",
"arrow-body-style": "warn",
"@typescript-eslint/no-use-before-define": "warn",
"object-curly-newline": "warn",
"no-trailing-spaces": "warn",
"operator-linebreak": "warn",
"no-await-in-loop": "warn",
"import/prefer-default-export": "warn",
"no-promise-executor-return": "warn",
"import/no-cycle": "warn",
"no-restricted-globals": "warn",
"import/no-extraneous-dependencies": ["warn", {
"devDependencies": ["**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**", "scripts/**", "src/spec/**"],
"optionalDependencies": true,
"peerDependencies": false
}],
"prefer-template": "warn",
"prefer-arrow-callback": "warn",
"import/no-relative-packages": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }]
}
},
{
"files": ["app/**/*.ts", "app/**/*.tsx"],
"parserOptions": {
"project": "./app/tsconfig.json"
},
"extends": [
"airbnb-base",
"airbnb-typescript/base"
],
"rules": {
"@typescript-eslint/semi": ["error", "never"],
"semi": "off",
"max-len": "warn",
"@typescript-eslint/indent": "warn",
"@typescript-eslint/no-throw-literal": "warn",
"arrow-parens": "warn",
"@typescript-eslint/comma-dangle": "warn",
"arrow-body-style": "warn",
"@typescript-eslint/no-use-before-define": "warn",
"object-curly-newline": "warn",
"no-trailing-spaces": "warn",
"operator-linebreak": "warn",
"no-await-in-loop": "warn",
"import/prefer-default-export": "warn",
"no-promise-executor-return": "warn",
"import/no-cycle": "warn",
"no-restricted-globals": "warn",
"import/no-extraneous-dependencies": ["warn", {
"devDependencies": ["**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**", "scripts/**", "src/spec/**"],
"optionalDependencies": true,
"peerDependencies": false
}],
"prefer-template": "warn",
"prefer-arrow-callback": "warn",
"import/no-relative-packages": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }]
}
},
{
"files": ["app/**/*.tsx"],
"parserOptions": {
"project": "./app/tsconfig.json"
},
"extends": [
"airbnb",
"airbnb-typescript",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"react",
"react-hooks",
"jsx-a11y"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"@typescript-eslint/semi": ["error", "never"],
"semi": "off",
"max-len": "off",
"@typescript-eslint/indent": "off",
"@typescript-eslint/no-throw-literal": "warn",
"react/jsx-filename-extension": ["error", { "extensions": [".tsx"] }],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-props-no-spreading": "warn",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/no-static-element-interactions": "warn",
"react/destructuring-assignment": "warn",
"react/no-access-state-in-setstate": "warn",
"react/sort-comp": "warn",
"react/no-unused-state": "warn",
"react/state-in-constructor": "warn",
"react/static-property-placement": "warn",
"react/no-array-index-key": "warn",
"react/display-name": "warn",
"react-compiler/react-compiler": "off",
"react/require-default-props": "warn",
"react/jsx-no-bind": "warn",
"arrow-parens": "warn",
"@typescript-eslint/comma-dangle": "warn",
"arrow-body-style": "warn",
"@typescript-eslint/no-use-before-define": "warn",
"object-curly-newline": "warn",
"no-trailing-spaces": "warn",
"operator-linebreak": "warn",
"no-await-in-loop": "warn",
"import/prefer-default-export": "warn",
"no-promise-executor-return": "warn",
"import/no-cycle": "warn",
"no-restricted-globals": "warn",
"import/no-extraneous-dependencies": ["warn", {
"devDependencies": ["**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**", "scripts/**", "src/spec/**"],
"optionalDependencies": true,
"peerDependencies": false
}],
"prefer-template": "warn",
"prefer-arrow-callback": "warn",
"import/no-relative-packages": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"react/jsx-boolean-value": "warn",
"react/jsx-one-expression-per-line": "warn"
}
},
{
"files": ["backend/**/*.ts"],
"parserOptions": {
"project": "./backend/tsconfig.json"
},
"extends": [
"airbnb-base",
"airbnb-typescript/base"
],
"rules": {
"@typescript-eslint/semi": ["error", "never"],
"semi": "off",
"max-len": "warn",
"@typescript-eslint/indent": "warn",
"@typescript-eslint/no-throw-literal": "warn",
"arrow-parens": "warn",
"@typescript-eslint/comma-dangle": "warn",
"arrow-body-style": "warn",
"@typescript-eslint/no-use-before-define": "warn",
"object-curly-newline": "warn",
"no-trailing-spaces": "warn",
"operator-linebreak": "warn",
"no-await-in-loop": "warn",
"import/prefer-default-export": "warn",
"no-promise-executor-return": "warn",
"import/no-cycle": "warn",
"no-restricted-globals": "warn",
"import/no-extraneous-dependencies": ["warn", {
"devDependencies": ["**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**", "scripts/**", "src/spec/**"],
"optionalDependencies": true,
"peerDependencies": false
}],
"prefer-template": "warn",
"prefer-arrow-callback": "warn",
"import/no-relative-packages": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }]
}
},
{
"files": ["*.spec.ts", "*.spec.tsx", "**/__tests__/**"],
"rules": {
"@typescript-eslint/no-unused-expressions": "off",
"import/no-extraneous-dependencies": "off"
}
}
]
}
+184 -219
View File
@@ -1,242 +1,207 @@
# copilot-instructions.md - Agent Long-Term Memory
## META-INSTRUCTIONS (IMMUTABLE)
1. **Long-term memory**: If you learn something during a session that would save time in future sessions, add it to `.github/copilot-instructions.md`
2. **Paper knowledge must go**: If something is no longer true, update or remove it immediately
3. **Evaluate after every session**: Consider whether the instructions need updates based on what you learned
4. **Concise and useful**: All information must be actionable, current, and concise
## Test Commands
**Unit tests:**
- `yarn test` - All unit tests (app + backend)
- `yarn test:app` - Frontend tests only
- `yarn test:backend` - Backend tests only
**Integration tests:**
- `yarn test:ui` - Browser tests (requires `yarn build` first)
- `yarn test:demo-video` - UI recording (requires Xvfb, mosquitto, tmux, ffmpeg)
- `yarn test:mcp` - Model Context Protocol tests
- `yarn test:all` - All tests (unit + demo-video)
- `./scripts/runBrowserTests.sh` - Browser mode UI tests (requires mosquitto service)
- `./scripts/uiTests.sh` - Demo video tests with Electron (requires Xvfb, mosquitto)
**CI jobs:** `test`, `ui-tests`, `demo-video`, `test-browser`, `browser-ui-tests`
**Important:**
- Browser UI tests require MQTT broker. In CI, GitHub Actions health checks ensure the mosquitto service is ready before tests run.
- Demo video tests use the same test scenarios as browser tests - if browser tests pass, demo video tests should pass too (they use identical selectors in `src/spec/scenarios/`)
## Debugging Demo Video / UI Tests
When demo video tests fail in CI but you need to debug locally:
**Prerequisites:**
```bash
# Install required packages
sudo apt-get install xvfb mosquitto tmux ffmpeg
# Ensure mosquitto is stopped (script will start its own instance)
sudo systemctl stop mosquitto
```
**Steps to debug:**
1. Build the project: `yarn build`
2. Run the demo video tests: `ELECTRON_DISABLE_SANDBOX=1 ./scripts/uiTests.sh`
3. If tests fail, check the error messages for:
- Missing `data-test` or `data-test-type` attributes
- Elements not visible (hidden, outside viewport, or covered by overlays)
- Click interception (tooltips, dialogs blocking clicks)
- XPath selector issues (check the data-test value format)
**Common issues:**
- **"locator not visible"**: Element exists but is hidden or outside viewport
- **"locator.click intercepted"**: Another element (tooltip, dialog) is covering the click target
- **Empty `data-test` attribute**: For simple numeric values, ensure you're using the topic name, not `props.literal.path` (which is empty for non-JSON values)
- **"Process failed to launch"**: Electron can't start - ensure DISPLAY is set and Xvfb is running
**Environment-specific notes:**
- Demo video tests use Electron with Xvfb (virtual display)
- Browser tests use Chromium without Electron (easier to debug locally)
- CI environment has proper Electron setup - if local tests are flaky, trust CI results
- Both test types use the same scenario files in `src/spec/scenarios/`
**Material-UI Tooltip considerations:**
- Tooltips wrap their children and create overlay divs
- Test attributes (`data-test-type`, `data-test`) must be on the inner clickable element that's passed as a child to the Tooltip
- Mouse event handlers (onMouseEnter, onMouseLeave, ref) go on outer wrapper
- onClick handler and test attributes go on the span inside the Tooltip
- The clickable child inside the Tooltip is what Playwright should target
**Example:**
```tsx
// ❌ WRONG - attributes on outer wrapper, Tooltip wraps and hides them
<Tooltip title="...">
<span onClick={...} data-test-type="Button" data-test="example">
<Icon />
</span>
</Tooltip>
// ✅ CORRECT - attributes on the actual clickable child inside Tooltip
<span ref={ref} onMouseEnter={...} onMouseLeave={...}>
<Tooltip title="...">
<span onClick={...} data-test-type="Button" data-test="example">
<Icon />
</span>
</Tooltip>
</span>
```
**data-test value format:**
- ShowChart: Use last segment of topic path (e.g., "heater" for "kitchen/coffee_maker/heater")
- ChartSettings: Use full topic + dotPath (e.g., "kitchen/coffee_maker/heater-" with trailing dash when dotPath is empty)
- Test XPaths use `contains(@data-test, "substring")` so partial matches work
## Running Browser Tests Locally
**Prerequisites:**
```bash
# Install mosquitto (if not already installed)
sudo apt-get install mosquitto
# Start mosquitto service
sudo systemctl start mosquitto
sudo systemctl status mosquitto
```
**Run browser tests:**
```bash
# Build server and run tests
yarn build:server
./scripts/runBrowserTests.sh
```
The script automatically:
- Starts a local mosquitto broker
- Builds the TypeScript code
- Starts the browser mode server on port 3000
- Runs Playwright tests in browser mode
- Cleans up processes on exit
**Test environment variables:**
- `MQTT_EXPLORER_USERNAME` - Browser auth username (default: test)
- `MQTT_EXPLORER_PASSWORD` - Browser auth password (default: test123)
- `PORT` - Server port (default: 3000)
- `TESTS_MQTT_BROKER_HOST` - MQTT broker host (default: 127.0.0.1)
- `TESTS_MQTT_BROKER_PORT` - MQTT broker port (default: 1883)
- `USE_MOBILE_VIEWPORT` - Enable mobile viewport (default: false)
**Common test failures after UI changes:**
- Update test selectors in `src/spec/ui-tests.spec.ts` if UI structure changes
- Use `data-testid` attributes for stable test selectors
- Avoid using role + name selectors for dynamic content (use direct testid selectors instead)
## Browser Mode
**Prerequisites:** Node.js ≥24, Yarn, Mosquitto broker (for testing)
**Development (hot reload):**
```bash
export MQTT_EXPLORER_USERNAME=admin MQTT_EXPLORER_PASSWORD=yourpass
yarn dev:server
# Backend: http://localhost:3000, Frontend: http://localhost:8080 (use this one)
```
**Production:**
```bash
yarn build:server
export MQTT_EXPLORER_USERNAME=admin MQTT_EXPLORER_PASSWORD=yourpass
yarn start:server # http://localhost:3000
```
**Build artifacts:** `dist/src/server.js`, `app/build/*.js`, `app/build/index.html`
# GitHub Copilot Agent Instructions for MQTT Explorer
## Debugging Browser Mode
**DevTools checks:**
- Console: JavaScript errors, CSP errors (security headers), WebSocket issues
- Network: Static asset loading, WebSocket status, API auth
### Prerequisites
- Node.js 24 or higher
- Yarn package manager
- Running Mosquitto MQTT broker (for testing)
**Common issues:**
- **Blank page/CSP errors:** Add `'unsafe-eval'` to `scriptSrc` in `src/server.ts` helmet config
- **Auth loop:** WebSocket auth failing - check Network → WS → Messages and server logs
- **Theme errors:** Verify both ThemeProvider and LegacyThemeProvider in `app/src/index.tsx`
### Development Mode (with Hot Reload)
**Expected warnings (non-fatal):**
- React 18 + Material-UI v5 type warnings
- IpcRendererEventBus errors (no Electron IPC in browser mode)
- MUI locale, componentWillReceiveProps, ACE editor warnings
1. **Set credentials (required):**
```bash
export MQTT_EXPLORER_USERNAME=admin
export MQTT_EXPLORER_PASSWORD=your_password
```
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)
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
**WebSocket debugging:**
```bash
node dist/src/server.js 2>&1 | tee server.log
# Check DevTools → Network → WS → Messages for handshake
# CORS: check ALLOWED_ORIGINS env var
# Start server in background
export MQTT_EXPLORER_USERNAME=admin
export MQTT_EXPLORER_PASSWORD=test123
node dist/src/server.js &
# 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
```
**Security notes:**
- `unsafe-eval` in CSP required for webpack (security tradeoff)
- Never hardcode credentials (use env vars)
- Production: use HTTPS with reverse proxy
- Rate limit: 5 auth attempts/15min/IP
- File upload limit: 16MB
### Expected UI Flow
**Key files:**
- `src/server.ts` - Express server, security middleware
- `app/webpack.browser.config.mjs` - Browser webpack config
- `app/src/browserEventBus.ts` - Socket.io client
- `app/src/components/BrowserAuthWrapper.tsx` - Auth dialog
- `app/src/index.tsx` - React entry, theme providers
1. **Login Page** (https://github.com/user-attachments/assets/383305e1-2169-433c-a668-5a05da0c343a)
- Enter username and password from environment variables
- Click "LOGIN" button
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
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
## Styling Conventions
### Debugging WebSocket Connection
When modifying or creating UI components, follow the styling patterns documented in <a>STYLING.md</a>.
1. **Check server logs:**
```bash
node dist/src/server.js 2>&1 | tee server.log
```
**Key points for AI agents:**
- Use Material-UI (MUI) v7 components with `withStyles` HOC for styling
- Access theme colors via `theme.palette.*`, spacing via `theme.spacing()`, typography via `theme.typography.*`
- Support both light and dark modes with theme-conditional styling
- Import Material-UI colors: `import { blueGrey, amber, green, red } from '@mui/material/colors'`
2. **Check browser WebSocket:**
- DevTools → Network → WS tab
- Look for socket.io connection
- Check Messages tab for authentication handshake
## Mobile Testing Workflow
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
**Prerequisites for mobile testing:**
### Development vs Production
**Development mode:**
```bash
# Install Playwright browsers
npx playwright install --with-deps chromium
# Configure mosquitto to allow anonymous connections (for local testing)
echo "listener 1883
allow_anonymous true" | sudo tee /etc/mosquitto/conf.d/allow-anonymous.conf
sudo systemctl restart mosquitto
yarn dev:server
# Runs webpack-dev-server with hot reload
# More verbose error messages
# Source maps enabled
```
**Interactive testing with mobile viewport:**
**Production mode:**
```bash
# Set up environment
export MQTT_EXPLORER_SKIP_AUTH=true
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
# Build and start server
yarn build:server
node dist/src/server.js
# In another terminal, run Playwright test with mobile viewport
# Create test script with viewport: { width: 412, height: 914 }
# Always INSPECT the rendered output, don't rely on assumptions
NODE_ENV=production yarn build:server
NODE_ENV=production node dist/src/server.js
# Minified bundles
# Generic error messages (security)
# HSTS enabled
```
**Key lesson**: Mobile tree visibility issues often stem from:
1. CSS flex/absolute positioning conflicts
2. Missing Redux state updates (connection not propagated to frontend)
3. MQTT broker authentication (mosquitto requires `allow_anonymous true` for testing)
4. Timing issues (frontend subscribing to events after backend emits them)
### Build Artifacts
**Server-side auto-connect** (for testing):
```bash
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
export MQTT_AUTO_CONNECT_PORT=1883 # optional
export MQTT_AUTO_CONNECT_PROTOCOL=mqtt # optional
```
After `yarn build:server`, check:
- `dist/src/server.js` - Compiled server code
- `app/build/*.js` - Webpack bundles
- `app/build/index.html` - Entry point HTML
### Troubleshooting Checklist
- [ ] 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
-4
View File
@@ -8,10 +8,6 @@ on:
- Dockerfile
- .github
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
create-image:
runs-on: ubuntu-latest
@@ -14,9 +14,6 @@ on:
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
env:
TESTS_MQTT_BROKER_HOST: localhost
TESTS_MQTT_BROKER_PORT: 1883
steps:
- name: Checkout code
-231
View File
@@ -1,231 +0,0 @@
name: Docker Browser Build
on:
push:
branches:
- master
- release
- beta
paths:
- 'Dockerfile.browser'
- 'src/server.ts'
- 'src/AuthManager.ts'
- 'app/**'
- 'backend/**'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/docker-browser.yml'
- 'tsconfig.json'
- 'events/**'
schedule:
# Run every two weeks (1st and 15th of each month) at 2:00 AM UTC
- cron: '0 2 1,15 * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
env:
TESTS_MQTT_BROKER_HOST: localhost
TESTS_MQTT_BROKER_PORT: 1883
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install and Start Mosquitto
run: |
sudo apt-get update
sudo apt-get install -y mosquitto mosquitto-clients
# Create a minimal configuration file for testing
sudo tee /etc/mosquitto/conf.d/test.conf > /dev/null <<EOF
listener 1883
allow_anonymous true
persistence false
EOF
# Start mosquitto in detached mode
sudo mosquitto -c /etc/mosquitto/mosquitto.conf -d
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.browser
platforms: linux/amd64
push: false
load: true
tags: mqtt-explorer:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image - Basic startup
run: |
# Start container with test credentials
docker run -d \
--name mqtt-explorer-test \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
-e PORT=3000 \
mqtt-explorer:test
# Wait for server to be ready (max 60 seconds)
echo "Waiting for server to start..."
for i in {1..60}; do
if curl -f http://localhost:3000 > /dev/null 2>&1; then
echo "Server started successfully after $i seconds"
break
fi
if [ $i -eq 60 ]; then
echo "Server failed to start within 60 seconds"
docker logs mqtt-explorer-test
exit 1
fi
sleep 1
done
- name: Test Docker image - Health check
run: |
# Wait for health check to pass
echo "Waiting for health check to pass..."
for i in {1..30}; do
health=$(docker inspect --format='{{.State.Health.Status}}' mqtt-explorer-test)
if [ "$health" = "healthy" ]; then
echo "Container is healthy"
break
fi
if [ $i -eq 30 ]; then
echo "Health check failed"
docker logs mqtt-explorer-test
exit 1
fi
sleep 2
done
- name: Test Docker image - Verify response
run: |
# Test that the server responds with HTML
response=$(curl -s http://localhost:3000)
if echo "$response" | grep -q "MQTT Explorer"; then
echo "Server is serving the application correctly"
else
echo "Server response does not contain expected content"
echo "Response: $response"
exit 1
fi
- name: Test Docker image - Verify data persistence
run: |
# Check that data directory was created
docker exec mqtt-explorer-test sh -c '[ -d /app/data ] && echo "Data directory exists"'
- name: Clean up test container
if: always()
run: |
docker stop mqtt-explorer-test || true
docker rm mqtt-explorer-test || true
- name: Check Docker image size
run: |
echo "### Docker Image Size" >> $GITHUB_STEP_SUMMARY
docker images mqtt-explorer:test --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" >> $GITHUB_STEP_SUMMARY
# Get size in bytes for detailed reporting
SIZE_BYTES=$(docker inspect mqtt-explorer:test --format='{{.Size}}')
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image size**: ${SIZE_MB} MB (${SIZE_BYTES} bytes)" >> $GITHUB_STEP_SUMMARY
echo "Image size: ${SIZE_MB} MB"
- name: Setup Node.js for browser tests
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'yarn'
- name: Install dependencies for browser tests
run: yarn install --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Start Docker container for browser tests
run: |
docker run -d \
--name mqtt-explorer-browser-test \
--network host \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
-e PORT=3000 \
mqtt-explorer:test
# Wait for server to be ready
echo "Waiting for Docker container to be ready..."
timeout 60 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
echo "Docker container is ready"
- name: Run browser test suite
run: |
yarn test:browser
env:
MQTT_EXPLORER_USERNAME: test
MQTT_EXPLORER_PASSWORD: test123
BROWSER_MODE_URL: http://localhost:3000
- name: Clean up browser test container
if: always()
run: |
docker logs mqtt-explorer-browser-test || true
docker stop mqtt-explorer-browser-test || true
docker rm mqtt-explorer-browser-test || true
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.browser
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
+51 -237
View File
@@ -2,10 +2,6 @@ on:
pull_request_target: # Use pull_request_target
branches: [master, beta, release]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -20,35 +16,34 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Packages
run: yarn install --frozen-lockfile
- name: Lint
run: yarn lint:eslint
- name: Build
run: yarn build
- name: Test
run: yarn test
browser-ui-tests:
ui-tests:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
volumes:
- ./:/app
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 Browser Mode
run: yarn build:server
- name: Run Browser UI Tests
- name: Build
run: yarn build
- name: Run UI Tests
timeout-minutes: 10
run: ./scripts/runBrowserTests.sh
run: ./scripts/runUiTests.sh
- name: Upload Test Screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: browser-test-screenshots
name: ui-test-screenshots
path: |
test-screenshot-*.png
retention-days: 30
@@ -59,6 +54,7 @@ jobs:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
volumes:
- ./:/app
options: --user root
steps:
- uses: actions/checkout@v4
with:
@@ -71,251 +67,69 @@ jobs:
run: yarn ui-test
- name: Post-processing
run: ./scripts/prepareVideo.sh
- name: Generate unique base path
id: basepath
run: |
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BASEPATH="pr-${{ github.event.pull_request.number }}-${TIMESTAMP}"
echo "basepath=${BASEPATH}" >> $GITHUB_OUTPUT
- name: Install AWS CLI v2
run: |
apt-get update && apt-get install -y unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
./aws/install
rm -rf aws awscliv2.zip
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
- uses: hkusu/s3-upload-action@v2
id: upload # specify some ID for use in subsequent steps
with:
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: 'eu-central-1'
- name: Upload full video to S3
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
# Upload GIF
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test.gif \
--body ./ui-test.gif \
--content-type image/gif
# Upload MP4
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test.mp4 \
--body ./ui-test.mp4 \
--content-type video/mp4
- name: Upload video segments to S3
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
shell: bash
run: |
# Upload all GIF segment files if they exist
shopt -s nullglob # Make glob return empty list if no matches
for segment in segment-*.gif; do
echo "Uploading $segment..."
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/${segment} \
--body ./${segment} \
--content-type image/gif
done
shopt -u nullglob # Restore default behavior
- name: Generate file URLs
id: fileurl
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
BASE_URL="https://${AWS_BUCKET}.s3.eu-central-1.amazonaws.com/artifacts/${BASEPATH}"
echo "base-url=${BASE_URL}" >> $GITHUB_OUTPUT
echo "Uploaded to: ${BASE_URL}"
- name: Generate markdown summary
id: markdown
env:
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
run: |
MARKDOWN=$(node ./scripts/generateMarkdownSummary.js "${BASE_URL}")
echo "markdown<<EOF" >> $GITHUB_OUTPUT
echo "$MARKDOWN" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Add to workflow summary
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
run: |
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
- name: Post video to PR
uses: actions/github-script@v7
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const markdown = process.env.MARKDOWN;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: markdown
});
demo-video-mobile:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
volumes:
- ./:/app
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Packages
run: yarn install --frozen-lockfile
- name: Build Browser Mode
run: yarn build:server
- name: Generate Mobile Demo Video
id: generate_video
continue-on-error: true
run: ./scripts/uiTestsMobile.sh
- name: Post-processing
if: always()
continue-on-error: true
run: ./scripts/prepareVideoMobile.sh
- name: Generate unique base path
id: basepath
run: |
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BASEPATH="pr-${{ github.event.pull_request.number }}-mobile-${TIMESTAMP}"
echo "basepath=${BASEPATH}" >> $GITHUB_OUTPUT
- name: Install AWS CLI v2
run: |
apt-get update && apt-get install -y unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
./aws/install
rm -rf aws awscliv2.zip
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: 'eu-central-1'
- name: Upload full mobile video to S3
if: always()
continue-on-error: true
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
# Upload GIF if it exists
if [ -f ./ui-test-mobile.gif ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.gif \
--body ./ui-test-mobile.gif \
--content-type image/gif
fi
# Upload MP4 if it exists
if [ -f ./ui-test-mobile.mp4 ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.mp4 \
--body ./ui-test-mobile.mp4 \
--content-type video/mp4
fi
- name: Upload mobile video segments to S3
if: always()
continue-on-error: true
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
shell: bash
run: |
# Upload all mobile GIF segment files if they exist
shopt -s nullglob # Make glob return empty list if no matches
for segment in segment-mobile-*.gif; do
echo "Uploading $segment..."
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/${segment} \
--body ./${segment} \
--content-type image/gif
done
shopt -u nullglob # Restore default behavior
- name: Generate file URLs
if: always()
id: fileurl
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
BASE_URL="https://${AWS_BUCKET}.s3.eu-central-1.amazonaws.com/artifacts/${BASEPATH}"
echo "base-url=${BASE_URL}" >> $GITHUB_OUTPUT
echo "Uploaded to: ${BASE_URL}"
- name: Generate markdown summary
if: always()
id: markdown
env:
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
TEST_STATUS: ${{ steps.generate_video.outcome }}
run: |
MARKDOWN=$(node ./scripts/generateMarkdownSummaryMobile.js "${BASE_URL}" "${TEST_STATUS}")
echo "markdown<<EOF" >> $GITHUB_OUTPUT
echo "$MARKDOWN" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Add to workflow summary
if: always()
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
run: |
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
- name: Post mobile video to PR
if: always()
uses: actions/github-script@v7
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const markdown = process.env.MARKDOWN;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: markdown
});
aws-bucket: ${{ vars.AWS_BUCKET }}
file-path: './ui-test.gif'
content-type: image/gif
output-file-url: 'true'
- name: Show URL
run: echo '${{ steps.upload.outputs.file-url }}'
id: artifact-upload-step
- run: echo '<picture><img src="${{ steps.upload.outputs.file-url }}"></picture>' >> $GITHUB_STEP_SUMMARY
test-browser:
runs-on: ubuntu-latest
env:
TESTS_MQTT_BROKER_HOST: localhost
TESTS_MQTT_BROKER_PORT: 1883
services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883
options: >-
--health-cmd "mosquitto_sub -t '$SYS/#' -C 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install System Dependencies
run: |
sudo apt-get update
sudo apt-get install -y mosquitto mosquitto-clients
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install Dependencies
run: yarn install --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Build Browser Mode
run: yarn build:server
- name: Test App
run: yarn test:app
- name: Test Backend
run: yarn test:backend
- name: Run Browser UI Tests
run: ./scripts/runBrowserTests.sh
- name: Start Server in Background
run: |
yarn start:server &
echo $! > server.pid
env:
MQTT_EXPLORER_USERNAME: test
MQTT_EXPLORER_PASSWORD: test123
PORT: 3000
- name: Wait for Server
run: |
timeout 30 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
- name: Browser Smoke Test
run: |
# Test server is running
curl -f http://localhost:3000 || exit 1
echo "Browser mode server is running successfully"
- name: Stop Server
if: always()
run: |
if [ -f server.pid ]; then
kill $(cat server.pid) || true
rm server.pid
fi
-4
View File
@@ -2,10 +2,6 @@ name: Update Website
on: [release, workflow_dispatch]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
update-website:
runs-on: ubuntu-latest
+1 -49
View File
@@ -18,53 +18,5 @@ test-mcp-introspection.js
/data
test-screenshot-*.png
test-expand-*.png
browser-debug-screenshot.png
app/.webpack-cache
# Temporary files
/tmp
# Demo video artifacts
scenes.json
scenes-mobile.json
segment-*.mp4
segment-*.gif
ui-test.mp4
ui-test.gif
ui-test-mobile.mp4
ui-test-mobile.gif
app.mp4
app2.mp4
app-mobile.mp4
app2-mobile.mp4
app720.gif
qrawvideorgb24.yuv
qrawvideorgb24-mobile.yuv
intro.png
intro-mobile.png
palette.png
palette-mobile.png
ffmpeg_info
ffmpeg_info_mobile# Mobile test artifacts
qrawvideorgb24-mobile.yuv
*.yuv
segment-mobile-*.gif
mobile-demo.mp4
mobile-demo.gif
final-mobile-tree.png
mobile-tree-debug.png
mobile-render-debug.png
tree-state-check.png
server*.log
# Test scripts
test-mobile-tree.js
check-*.js
debug-*.js
inspect-*.js
final-*.js
publish-test*.js
verify-mobile-tree.js
interactive-mobile-test.js
long-wait-test.js
app/.webpack-cache
+16 -196
View File
@@ -10,57 +10,6 @@ MQTT Explorer uses GitHub Actions for continuous integration and testing. The pi
This workflow runs on pull requests to `master`, `beta`, and `release` branches.
### Docker Browser Build Workflow (`.github/workflows/docker-browser.yml`)
This workflow builds and publishes a Docker image for the browser mode.
**Triggers**:
- Push to `master`, `beta`, or `release` branches (when relevant files change)
- Schedule: Runs every two weeks (1st and 15th of each month at 2:00 AM UTC)
- Manual trigger via workflow_dispatch
**Platforms**:
- linux/amd64 (x86-64)
- linux/arm64 (Raspberry Pi 3/4/5, Apple Silicon)
- linux/arm/v7 (Raspberry Pi 2/3)
**Image Registry**: GitHub Container Registry (ghcr.io/thomasnordquist/mqtt-explorer)
**Tags**:
- `latest` - Latest build from master branch
- `master` - Latest build from master
- `beta` - Latest build from beta branch
- `release` - Latest build from release branch
- `<branch>-<sha>` - Specific commit builds
**Steps**:
1. Build Docker image with multi-stage build
2. Test basic startup with test credentials
3. Test health check
4. Verify HTTP response
5. Test data directory creation
6. Check Docker image size
7. Setup Node.js 24 for browser tests
8. Install dependencies for browser tests
9. Install Playwright browsers (`npx playwright install --with-deps chromium`)
10. Start container for browser tests
11. Run browser test suite with Playwright
12. Push image to GitHub Container Registry
13. Generate build attestation for supply chain security
**Image Features**:
- Multi-stage build for minimal size
- Alpine Linux base with Node.js 24 (~200MB final image)
- Multi-platform support (amd64, arm64, arm/v7)
- Non-root user (UID 1001)
- Health check endpoint
- Proper signal handling with dumb-init
- Persistent data volume at `/app/data`
### Test Workflow (`.github/workflows/tests.yml`)
This workflow runs on pull requests to `master`, `beta`, and `release` branches.
#### Jobs
##### 1. `test` - Electron Mode Tests
@@ -68,46 +17,34 @@ This workflow runs on pull requests to `master`, `beta`, and `release` branches.
Tests the traditional Electron desktop application:
- **Environment**: Custom Docker container (`ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest`)
- Based on Node.js 24
- Includes Xvfb for headless display
- Includes FFmpeg for video recording
- Includes Mosquitto MQTT broker
- **Playwright browsers pre-installed** with system dependencies
- **Steps**:
1. Install dependencies with frozen lockfile
2. Build the Electron application
3. Run unit tests (app + backend)
4. Run UI tests with video recording
5. Upload test video to S3 with 90-day expiration tag
6. Post demo video to PR as comment
7. Display test results in GitHub summary
5. Upload test video to S3
6. Display test results in GitHub summary
**Artifacts**:
- UI test video (GIF format) uploaded to S3 using AWS CLI
- Video is tagged with `expiration=90days` for automatic lifecycle deletion
- Video is posted to the PR thread as an embedded image
- Videos expire after 90 days via S3 lifecycle policy
**Artifacts**: UI test video (GIF format) uploaded to S3
##### 2. `test-browser` - Browser Mode Tests
Tests the new browser/server mode:
- **Environment**: Ubuntu latest with Node.js 24
- **MQTT Broker**: Mosquitto v2 on port 1883
- Started detached with `-d` flag
- Anonymous connections allowed
- No persistence
- **Environment**: Ubuntu latest with Node.js 20
- **Services**:
- **Mosquitto MQTT Broker**: Eclipse Mosquitto v2 on port 1883
- Health checks enabled
- Anonymous connections allowed
- **Steps**:
1. Install and start Mosquitto in detached mode
2. Setup Node.js 24
3. Install dependencies
4. Install Playwright browsers (`npx playwright install --with-deps chromium`)
5. Build browser mode (`yarn build:server`)
6. Run unit tests (app + backend)
7. Start server in background with test credentials
8. Wait for server to be ready
9. Run browser smoke tests
10. Clean up server process
1. Setup Node.js 20
2. Install dependencies
3. Build browser mode (`yarn build:server`)
4. Run unit tests (app + backend)
5. Start server in background with test credentials
6. Wait for server to be ready
7. Run browser smoke tests
8. Clean up server process
**Environment Variables**:
- `MQTT_EXPLORER_USERNAME=test`
@@ -155,35 +92,6 @@ Example:
## Local Testing
### Docker Browser Mode
```bash
# Build the image locally (for your platform)
docker build -f Dockerfile.browser -t mqtt-explorer:local .
# Build for specific platform (e.g., Raspberry Pi)
docker buildx build --platform linux/arm64 -f Dockerfile.browser -t mqtt-explorer:local-arm64 .
# Run the container
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=test \
-e MQTT_EXPLORER_PASSWORD=test123 \
mqtt-explorer:local
# Test the server
curl http://localhost:3000
# Check logs
docker logs <container-id>
# Stop and remove
docker stop <container-id>
docker rm <container-id>
```
See [DOCKER.md](DOCKER.md) for complete documentation.
### Electron Mode
```bash
@@ -219,93 +127,6 @@ The repository includes a devcontainer configuration that automatically sets up:
See [.devcontainer/README.md](.devcontainer/README.md) for details.
## S3 Configuration for Demo Videos
### Required S3 Lifecycle Policy
Demo videos uploaded from PRs are tagged with `expiration=90days` and require an S3 lifecycle policy to automatically delete them after 90 days.
**Important**: The `video.mp4` file in the gh-pages branch is NOT tagged and will NOT expire.
#### Setting up the Lifecycle Policy
1. Create a file named `s3-lifecycle-pr-videos.json`:
```json
{
"Rules": [
{
"ID": "ExpirePRDemoVideosAfter90Days",
"Status": "Enabled",
"Filter": {
"Tag": {
"Key": "expiration",
"Value": "90days"
}
},
"Expiration": {
"Days": 90
}
}
]
}
```
2. Apply the policy to your S3 bucket:
```bash
aws s3api put-bucket-lifecycle-configuration \
--bucket YOUR_BUCKET_NAME \
--lifecycle-configuration file://s3-lifecycle-pr-videos.json
```
3. Verify the policy:
```bash
aws s3api get-bucket-lifecycle-configuration --bucket YOUR_BUCKET_NAME
```
#### How It Works
- **PR demo videos**: Uploaded with filename pattern `pr-{number}-{timestamp}.gif` and tagged with:
- `expiration=90days` - Used by lifecycle policy for automatic deletion
- `Source=github-actions` - Identifies source of upload
- `Type=pr-demo-video` - Categorizes the object type
- **S3 lifecycle rule**: Automatically deletes objects tagged with `expiration=90days` after 90 days
- **Upload mechanism**: AWS CLI v2 is installed directly, authentication is configured via `aws-actions/configure-aws-credentials@v4` GitHub Action, then `aws s3api put-object` is used with object tagging support
- **gh-pages video**: `video.mp4` in gh-pages branch is served from GitHub Pages, not S3, so it persists indefinitely
#### Required AWS Credentials
The workflow requires the following secrets/variables:
- `vars.AWS_KEY_ID` - AWS access key ID (requires `s3:PutObject` and `s3:PutObjectTagging` permissions)
- `secrets.AWS_SECRET_ACCESS_KEY` - AWS secret access key
- `vars.AWS_BUCKET` - S3 bucket name
- AWS region: `eu-central-1` (hardcoded in workflow)
The S3 bucket must have:
- **Bucket policy for public read access**: Since ACLs are disabled (BucketOwnerEnforced), a bucket policy must grant public read access to uploaded objects
- Object tagging enabled
- Lifecycle policy configured as described above
**Example S3 Bucket Policy for Public Read Access**:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
}
]
}
```
The workflow uses AWS CLI v2 installed directly and `aws-actions/configure-aws-credentials@v4` action for secure credential management.
## Troubleshooting
### Browser Tests Failing
@@ -321,7 +142,6 @@ The workflow uses AWS CLI v2 installed directly and `aws-actions/configure-aws-c
## Future Improvements
- [x] Add Playwright browser installation to workflows (browser tests can now use Playwright)
- [ ] Add E2E browser tests with Playwright
- [ ] Test WebSocket connections in browser mode
- [ ] Add performance benchmarks
-252
View File
@@ -1,252 +0,0 @@
# MQTT Explorer - Docker Browser Mode
Docker image for running MQTT Explorer in browser mode.
## Try It Now
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
Click the badge above to instantly try MQTT Explorer in your browser using Play with Docker (requires free Docker Hub account).
## Quick Start
### Using Pre-built Image
Pull and run the latest image from GitHub Container Registry:
```bash
docker pull ghcr.io/thomasnordquist/mqtt-explorer:latest
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=your_secure_password \
-v mqtt-explorer-data:/app/data \
--name mqtt-explorer \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
Access the application at `http://localhost:3000`
### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
mqtt-explorer:
image: ghcr.io/thomasnordquist/mqtt-explorer:latest
ports:
- "3000:3000"
environment:
- MQTT_EXPLORER_USERNAME=admin
- MQTT_EXPLORER_PASSWORD=your_secure_password
- PORT=3000
volumes:
- mqtt-explorer-data:/app/data
restart: unless-stopped
volumes:
mqtt-explorer-data:
```
Then run:
```bash
docker-compose up -d
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `MQTT_EXPLORER_USERNAME` | No | Generated | Username for authentication |
| `MQTT_EXPLORER_PASSWORD` | No | Generated | Password for authentication |
| `MQTT_EXPLORER_SKIP_AUTH` | No | `false` | Set to `true` to disable authentication (use only behind a secure proxy!) |
| `PORT` | No | `3000` | Port the server listens on |
| `ALLOWED_ORIGINS` | No | `*` | Comma-separated list of allowed CORS origins |
| `NODE_ENV` | No | - | Set to `production` for production deployments |
| `UPGRADE_INSECURE_REQUESTS` | No | `false` | Set to `true` to enable CSP upgrade-insecure-requests directive. **Only use when deployed behind an HTTPS reverse proxy (nginx, Traefik, etc.) with valid SSL certificates.** This upgrades all HTTP requests to HTTPS and will break direct HTTP access. |
| `X_FRAME_OPTIONS` | No | `false` | Set to `true` to enable X-Frame-Options: SAMEORIGIN header to prevent clickjacking. **Disables iframe embedding when enabled.** |
### Authentication Modes
**Standard Mode (Default):**
- Requires username and password for access
- Credentials can be set via environment variables or auto-generated
- Auto-generated credentials are logged on first startup and saved to `/app/data/credentials.json`
**Skip Authentication Mode (Use with caution!):**
```bash
docker run -d -p 3000:3000 \
-e MQTT_EXPLORER_SKIP_AUTH=true \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
⚠️ **WARNING**: When `MQTT_EXPLORER_SKIP_AUTH=true`, the application is **completely open** without any authentication. This should **only be used** when MQTT Explorer is deployed behind a secure authentication proxy (e.g., OAuth2 Proxy, Authelia, Nginx with auth_request) or in a trusted private network.
**Recommended use case**: Integration with enterprise SSO systems where authentication is handled by a reverse proxy.
**Note**: If credentials are not provided and auth is not skipped, they will be auto-generated and stored in `/app/data/credentials.json`. Check the container logs to see the generated credentials:
```bash
docker logs mqtt-explorer
```
## Data Persistence
The container stores data in `/app/data`, including:
- User credentials (`credentials.json`)
- Connection settings (`settings.json`)
- Uploaded certificates (`certificates/`)
- File uploads (`uploads/`)
Mount a volume to persist data across container restarts:
```bash
docker run -v mqtt-explorer-data:/app/data ...
```
## Building from Source
Build the Docker image locally:
```bash
docker build -f Dockerfile.browser -t mqtt-explorer:local .
```
Run the locally built image:
```bash
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=secret \
mqtt-explorer:local
```
## Health Check
The container includes a health check that runs every 30 seconds. Check the health status:
```bash
docker inspect --format='{{.State.Health.Status}}' mqtt-explorer
```
## Security Best Practices
1. **Use HTTPS in Production**: Put the container behind a reverse proxy (nginx, Traefik) with HTTPS
2. **Set Strong Credentials**: Always set custom credentials via environment variables
3. **Network Isolation**: Run in a private network when possible
4. **Update Regularly**: Pull the latest image regularly for security updates
### Example with Nginx Reverse Proxy
```nginx
server {
listen 443 ssl http2;
server_name mqtt-explorer.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Troubleshooting
### Container won't start
Check the logs:
```bash
docker logs mqtt-explorer
```
### Can't access the application
1. Verify the container is running: `docker ps`
2. Check the port mapping: `docker port mqtt-explorer`
3. Test connectivity: `curl http://localhost:3000`
### Authentication issues
1. Check generated credentials in logs: `docker logs mqtt-explorer`
2. Verify environment variables: `docker inspect mqtt-explorer`
3. Reset credentials by removing the data volume and restarting
### Permission issues
The container runs as a non-root user (UID 1001). If mounting host directories, ensure they're writable:
```bash
chown -R 1001:1001 /path/to/host/data
docker run -v /path/to/host/data:/app/data ...
```
## Available Tags
- `latest` - Latest stable version from the master branch
- `master` - Latest build from master branch
- `beta` - Latest beta version
- `release` - Latest release version
- `master-<sha>` - Specific commit from master
- `beta-<sha>` - Specific commit from beta
- `release-<sha>` - Specific commit from release
## Supported Platforms
The Docker image is built for multiple architectures:
- `linux/amd64` - x86-64 (standard PCs, servers)
- `linux/arm64` - ARM 64-bit (Raspberry Pi 3/4/5, Apple Silicon)
- `linux/arm/v7` - ARM 32-bit (Raspberry Pi 2/3)
## One-Click Deployment Options
### Play with Docker (Free)
Try MQTT Explorer instantly in your browser without installing anything:
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
- **No installation required** - Runs entirely in your browser
- **Free to use** - Requires only a Docker Hub account
- **Perfect for demos** - Great for testing and demonstrations
- **4-hour sessions** - Sessions automatically expire after 4 hours
### Cloud Platforms
Deploy MQTT Explorer to various cloud platforms with one click:
#### DigitalOcean App Platform
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/thomasnordquist/MQTT-Explorer/tree/master&refcode=docker)
- Automatically detects Docker configuration
- Managed platform with auto-scaling
- Starting at $5/month
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=docker&name=mqtt-explorer&image=ghcr.io/thomasnordquist/mqtt-explorer:latest&ports=3000;http;/)
- Deploy directly from Docker image
- Global edge network
- Free tier available
**Note:** Remember to set the environment variables `MQTT_EXPLORER_USERNAME` and `MQTT_EXPLORER_PASSWORD` when deploying to cloud platforms.
## License
See the main [LICENSE.md](LICENSE.md) file.
-11
View File
@@ -10,17 +10,6 @@ ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
# Configure mosquitto for anonymous access (required for tests)
RUN mkdir -p /etc/mosquitto/conf.d && \
echo "listener 1883" > /etc/mosquitto/conf.d/default.conf && \
echo "allow_anonymous true" >> /etc/mosquitto/conf.d/default.conf && \
echo "persistence false" >> /etc/mosquitto/conf.d/default.conf
# Install Playwright and browsers
# This ensures Playwright browsers are pre-installed in the container
RUN npm install -g playwright@1.57.0 && \
npx playwright install --with-deps chromium
CMD /bin/bash
VOLUME /app
-69
View File
@@ -1,69 +0,0 @@
# Multi-stage build for MQTT Explorer Browser Mode
# Stage 1: Build and prepare production dependencies
FROM node:22-alpine AS builder
WORKDIR /build
# Copy all source files and dependencies
COPY package.json yarn.lock ./
COPY tsconfig.json ./
COPY src ./src
COPY backend ./backend
COPY events ./events
COPY app ./app
# Install ALL dependencies (needed for build)
RUN yarn install --frozen-lockfile --network-timeout 100000
# Build the application (compiles TypeScript and webpack bundles)
RUN yarn build:server
# Remove dev dependencies, keeping only production dependencies
RUN yarn install --production --frozen-lockfile --network-timeout 100000 && \
yarn cache clean && \
rm -rf /tmp/*
# Stage 2: Production
FROM node:22-alpine
# Install dumb-init in a single layer
RUN apk add --no-cache dumb-init
# Create app user in a single layer
RUN addgroup -g 1001 -S mqttexplorer && \
adduser -u 1001 -S mqttexplorer -G mqttexplorer
WORKDIR /app
# Copy ONLY the compiled dist folder (contains compiled TypeScript)
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/dist ./dist
# Copy ONLY the built frontend app
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/app/build ./app/build
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/app/index.html ./app/
# Copy runtime node_modules (production dependencies only)
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/node_modules ./node_modules
# Copy package.json for version info (needed by server)
COPY --from=builder --chown=mqttexplorer:mqttexplorer /build/package.json ./
# Create data directory for persistent storage
RUN mkdir -p /app/data && \
chown -R mqttexplorer:mqttexplorer /app/data
# Switch to non-root user
USER mqttexplorer
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "const http = require('http'); const req = http.get('http://localhost:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1)); req.on('error', () => process.exit(1));"
# Use dumb-init to handle signals properly
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Start the server
CMD ["node", "dist/src/server.js"]
-193
View File
@@ -1,193 +0,0 @@
# Environment Variables for LLM Integration
This document provides examples of how to configure the AI Assistant using environment variables for server deployments.
## Basic Configuration
```bash
# Set up OpenAI as the provider
export LLM_PROVIDER=openai
export OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx
# Or use Gemini
export LLM_PROVIDER=gemini
export GEMINI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxx
# Or use the generic LLM_API_KEY (works with either provider)
export LLM_PROVIDER=openai
export LLM_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx
```
## Advanced Configuration
```bash
# Configure token limit for neighboring topics context
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 # Default: 100 tokens
# Example: Increase token limit for more context
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=200
# Example: Decrease token limit to reduce API costs
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=50
```
## Complete Example for Server Deployment
```bash
#!/bin/bash
# start-mqtt-explorer.sh
# MQTT Connection
export MQTT_EXPLORER_SKIP_AUTH=true # Or configure authentication
export MQTT_AUTO_CONNECT_HOST=mqtt.example.com
export MQTT_AUTO_CONNECT_PORT=1883
# LLM Configuration
export LLM_PROVIDER=gemini
export GEMINI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxx
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100
# Start the server
node dist/src/server.js
```
## Docker Example
```dockerfile
# Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN yarn install && yarn build:server
# Environment variables can be set at runtime
ENV LLM_PROVIDER=openai
ENV LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100
EXPOSE 3000
CMD ["node", "dist/src/server.js"]
```
```bash
# Run with docker
docker run -d \
-e OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx \
-e LLM_PROVIDER=openai \
-e LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 \
-e MQTT_AUTO_CONNECT_HOST=mqtt.example.com \
-p 3000:3000 \
mqtt-explorer
```
## Context Generation with Token Limits
The `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT` controls how many tokens are allocated for neighboring topics in the context. Here's what happens:
### With Default 100 Tokens
```
Topic Path: sensors/living_room/temperature
Value: 22.5
Status: Retained
Related Topics (5 shown):
humidity: 65
pressure: 1013.25
air_quality: {"pm25":12,"pm10":8,"co2":450,"voc":120}
motion: false
light_level: 450
Message Count: 1
Subtopics: 0
```
### With 50 Tokens (Reduced)
```
Topic Path: sensors/living_room/temperature
Value: 22.5
Status: Retained
Related Topics (3 shown):
humidity: 65
pressure: 1013.25
air_quality: {"pm25":12,"pm10":8...
Message Count: 1
Subtopics: 0
```
### With 200 Tokens (Increased)
```
Topic Path: sensors/living_room/temperature
Value: 22.5
Status: Retained
Related Topics (8 shown):
humidity: 65
pressure: 1013.25
air_quality: {"pm25":12,"pm10":8,"co2":450,"voc":120}
motion: false
light_level: 450
battery: 85
signal_strength: -45
last_seen: 2026-01-26T23:45:00Z
Message Count: 1
Subtopics: 0
```
## Priority Order
The AI Assistant checks configuration in this order:
1. **Environment Variables** (highest priority)
- Provider-specific: `OPENAI_API_KEY`, `GEMINI_API_KEY`
- Generic fallback: `LLM_API_KEY`
- Provider selection: `LLM_PROVIDER`
- Token limit: `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT`
2. **localStorage** (browser/UI configuration)
- Set via the configuration dialog in the UI
- Only used if environment variables are not set
3. **Defaults** (lowest priority)
- Provider: `openai`
- Token limit: `100`
## Security Recommendations
- **Never commit API keys** to version control
- Use environment variables or secrets management
- In production, use `.env` files (not committed) or container secrets
- Rotate API keys regularly
- Monitor API usage and set billing alerts
## Troubleshooting
### API Key Not Working
Check priority order:
```bash
# Check if env vars are set
echo $OPENAI_API_KEY
echo $LLM_API_KEY
echo $LLM_PROVIDER
# Verify they're available to the Node process
node -e "console.log(process.env.OPENAI_API_KEY)"
```
### Token Limit Too Low
If you're seeing truncated context:
```bash
# Increase the token limit
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=200
```
### Want to Use UI Configuration
Simply don't set the environment variables - the UI configuration will be used instead.
-222
View File
@@ -1,222 +0,0 @@
# LLM Integration Implementation Summary
## Overview
This implementation adds an AI-powered assistant to MQTT Explorer that helps users interact with and understand MQTT topics using Large Language Models (LLMs). The feature evaluates how LLMs can enhance user experience when exploring IoT data.
## Implementation Approach
### Minimal, Surgical Changes
The implementation follows the principle of making the **smallest possible changes** to achieve the goal:
- **3 new files created**:
- `app/src/services/llmService.ts` (229 lines) - LLM service layer
- `app/src/components/Sidebar/AIAssistant.tsx` (387 lines) - UI component
- `LLM_INTEGRATION.md` (198 lines) - User documentation
- **1 existing file modified**:
- `app/src/components/Sidebar/DetailsTab.tsx` (4 lines changed) - Integration point
- **Total new code**: ~620 lines
- **Total modified code**: 4 lines
### Architecture
```
User Interface (React + Material-UI)
AIAssistant Component
LLMService (Singleton)
OpenAI API (via Axios)
```
## Key Features
### 1. Contextual Understanding
The AI assistant automatically extracts relevant context from selected MQTT topics:
- Topic path
- Message metadata (timestamp, QoS, retained status)
- Current value
- Message count and subtopics
### 2. Quick Suggestions
Pre-generated questions based on topic characteristics:
- "Explain this data structure" (for topics with payloads)
- "What does this value mean?" (for topics with values)
- "Summarize all subtopics" (for parent topics)
- "What can I do with this topic?" (universal suggestion)
### 3. Conversational Interface
- Chat-style interaction with message history
- Maintains context across multiple questions
- Collapsible panel to save screen space
- Loading states and error handling
### 4. Privacy-Conscious Design
- API keys stored locally (localStorage)
- No data sent to MQTT Explorer servers
- Clear documentation about data sharing with OpenAI
- User control over when to use the feature
## Code Quality
### TypeScript Best Practices
- ✅ Proper type definitions (no `any` types)
- ✅ Interface-based design
- ✅ Null safety with explicit checks
- ✅ Type guards for error handling
### Security
- ✅ CodeQL scan passed with 0 vulnerabilities
- ✅ API key stored securely in localStorage
- ✅ Rate limiting error handling
- ✅ Timeout protection (30s)
- ✅ Input validation
### Testing
- ✅ All existing unit tests pass (79 tests)
- ✅ Manual testing verified
- ✅ Cross-browser compatibility (tested in Chromium)
- ✅ No breaking changes to existing functionality
## User Experience
### Before
Users had to:
- Manually interpret MQTT message data
- Search documentation for MQTT concepts
- Use external tools to understand IoT data patterns
### After
Users can:
- Ask natural language questions about topics
- Get instant explanations of data structures
- Learn MQTT concepts in context
- Discover possibilities for topic usage
## Technical Highlights
### 1. Singleton Pattern for LLM Service
Ensures a single instance manages all API calls and conversation history:
```typescript
export function getLLMService(): LLMService {
if (!llmServiceInstance) {
llmServiceInstance = new LLMService()
}
return llmServiceInstance
}
```
### 2. Context Generation
Automatically extracts meaningful information from topics:
```typescript
public generateTopicContext(topic: TopicType): string {
const context = []
if (topic.path) context.push(`Topic Path: ${topic.path()}`)
if (topic.message) {
context.push(`Timestamp: ${topic.message.received}`)
context.push(`QoS: ${topic.message.qos}`)
// ... more context
}
return context.join('\n')
}
```
### 3. Conversation History Management
Maintains last 10 messages plus system prompt for efficient API usage:
```typescript
if (this.conversationHistory.length > 11) {
this.conversationHistory = [
this.conversationHistory[0], // System message
...this.conversationHistory.slice(-10) // Last 10 messages
]
}
```
### 4. Material-UI Integration
Follows existing design patterns with proper theming:
```typescript
const styles = (theme: Theme) => ({
root: {
marginTop: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
},
// ... consistent with existing components
})
```
## Evaluation Metrics
### How LLMs Help Users Interact with Topics
1. **Reduced Learning Curve**
- Users don't need to understand MQTT protocol details immediately
- Natural language interaction lowers barrier to entry
- Contextual help exactly when needed
2. **Faster Problem Resolution**
- Instant answers to common questions
- No need to leave the application
- Personalized explanations based on actual data
3. **Discovery & Exploration**
- Suggests actions users might not have considered
- Helps understand complex data structures
- Reveals patterns in MQTT usage
4. **Knowledge Building**
- Users learn MQTT concepts through interaction
- Explanations tailored to their specific use case
- Builds confidence in using MQTT
## Future Enhancements
Potential improvements identified:
1. **Multi-Provider Support**
- Anthropic Claude
- Azure OpenAI
- Local LLM models
2. **Enhanced Context**
- Historical message patterns
- Related topics analysis
- Device identification
3. **Automation Integration**
- Generate automation rules from descriptions
- Create custom dashboards
- Export scripts for common tasks
4. **Collaboration Features**
- Share helpful conversations
- Template library for common queries
- Team knowledge base
## Conclusion
This implementation successfully demonstrates how LLMs can enhance user interaction with MQTT topics by:
- ✅ Providing instant, contextual help
- ✅ Lowering the learning curve for MQTT concepts
- ✅ Enabling natural language interaction with technical data
- ✅ Maintaining privacy and security
- ✅ Integrating seamlessly with existing UI
The feature is production-ready, well-documented, and follows all best practices for code quality and security.
---
**Total Implementation Time**: ~2 hours
**Lines of Code**: ~620 new, 4 modified
**Test Coverage**: All existing tests pass
**Security**: Zero vulnerabilities detected
**Documentation**: Comprehensive user guide included
+32 -17
View File
@@ -1,6 +1,6 @@
When distributing, the attribution and donation page may not be altered or made less accessible without explicit approval.
# Creative Commons Attribution-NoDerivatives 4.0 International
# Creative Commons Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
@@ -12,33 +12,37 @@ Creative Commons public licenses provide a standard set of terms and conditions
* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
## Creative Commons Attribution-NoDerivatives 4.0 International Public License
## Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
### Section 1 Definitions.
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License.
d. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and NoDerivatives.
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
k. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
### Section 2 Scope.
@@ -46,9 +50,9 @@ a. ___License grant.___
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; but not
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, or Share Adapted Material.
B. produce, reproduce, and Share Adapted Material.
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
@@ -60,8 +64,9 @@ a. ___License grant.___
A. __Offer from the Licensor Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
B. __Additional offer from the Licensor Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapters License You apply.
C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
@@ -101,13 +106,23 @@ a. ___Attribution.___
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ___ShareAlike.___
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapters License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
### Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
-203
View File
@@ -1,203 +0,0 @@
# LLM Integration for Topic Interaction
## Overview
MQTT Explorer now includes an AI-powered assistant to help users understand and interact with MQTT topics and their data. This feature uses Large Language Models (LLMs) to provide intelligent insights, explanations, and suggestions about your MQTT data.
## Features
### AI Assistant Panel
The AI Assistant appears in the Details tab when you select any topic in the tree. It provides:
- **Quick Suggestions**: Pre-generated questions based on the selected topic
- **Interactive Chat**: Ask custom questions about the topic, its data, or MQTT concepts
- **Context-Aware**: Automatically includes topic metadata and message details in queries
- **Conversation History**: Maintains context across multiple questions
- **Collapsible Interface**: Minimizes when not needed to save screen space
### Capabilities
The AI Assistant can help you:
1. **Understand Data Structures**: Get explanations of JSON payloads and complex data formats
2. **Interpret Values**: Learn what specific values mean in the context of IoT devices
3. **Analyze Patterns**: Understand message patterns and frequencies
4. **Discover Possibilities**: Learn what actions you can perform with specific topics
5. **Learn MQTT Concepts**: Get answers about QoS, retained messages, and MQTT protocol features
## Configuration
### Setting Up Your API Key
#### Via UI (Browser/Electron)
1. Click the ⚙️ settings icon in the AI Assistant panel
2. Select your preferred provider (OpenAI or Gemini)
3. Enter your API key
4. Click "Save"
Your API key is stored locally in your browser's localStorage and is never sent to MQTT Explorer's servers.
#### Via Environment Variables (Server Mode)
For server deployments, you can configure the AI Assistant using environment variables:
```bash
# Provider selection (optional, defaults to 'openai')
export LLM_PROVIDER=openai # or 'gemini'
# API Keys - provider-specific or generic
export OPENAI_API_KEY=sk-... # For OpenAI
export GEMINI_API_KEY=AIza... # For Gemini
export LLM_API_KEY=... # Generic fallback for either provider
# Token limit for neighboring topics context (optional, defaults to 100)
export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100
```
**Environment Variable Priority:**
1. Provider-specific keys (`OPENAI_API_KEY`, `GEMINI_API_KEY`) are checked first
2. Generic `LLM_API_KEY` is used as fallback
3. UI-configured keys in localStorage are used if no environment variables are set
### Getting API Keys
#### OpenAI API Key
1. Visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)
2. Sign up or log in to your OpenAI account
3. Create a new API key
4. Copy the key and paste it into MQTT Explorer's configuration dialog or set `OPENAI_API_KEY` environment variable
**Note**: Using the AI Assistant will consume OpenAI API credits based on your usage. Please review OpenAI's pricing at [https://openai.com/pricing](https://openai.com/pricing).
#### Google Gemini API Key
1. Visit [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
2. Sign in with your Google account
3. Create a new API key
4. Copy the key and paste it into MQTT Explorer's configuration dialog or set `GEMINI_API_KEY` environment variable
**Note**: Google Gemini offers a generous free tier. Review Google's pricing at [https://ai.google.dev/pricing](https://ai.google.dev/pricing).
## Usage
### Basic Interaction
1. **Connect** to your MQTT broker
2. **Select** a topic from the tree
3. **Expand** the "AI Assistant" panel in the Details tab
4. **Click** on a quick suggestion or type your own question
5. **Send** your question and wait for the AI response
### Example Questions
- "What does this temperature value represent?"
- "How should I interpret this JSON structure?"
- "Why is this message retained?"
- "What QoS level should I use for this topic?"
- "How can I monitor changes to this value?"
- "What devices typically publish to topics like this?"
### Quick Suggestions
The AI Assistant provides contextual suggestions based on the selected topic:
- **"Explain this data structure"**: Get a breakdown of complex payloads
- **"What does this value mean?"**: Understand specific measurements or states
- **"Summarize all subtopics"**: Get an overview of nested topic hierarchies
- **"What can I do with this topic?"**: Discover possible actions and integrations
### Context Intelligence
The AI Assistant automatically includes relevant context with your questions:
- **Current Topic**: The selected topic path and its current value (with preview for large payloads)
- **Neighboring Topics**: Related topics (siblings and children) with their values, limited to 100 tokens by default
- **Topic Metadata**: Message count, subtopic count, and retained status
- **Smart Truncation**: Large values and topic lists are intelligently truncated to stay within token limits
The neighboring topics context can be adjusted using the `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT` environment variable for server deployments.
## Privacy & Security
### Data Handling
- **API Keys**: Stored locally in browser localStorage, never transmitted to MQTT Explorer servers
- **Topic Data**: Topic paths and message payloads are sent to OpenAI's API to provide context
- **Conversation History**: Maintained client-side and reset when you clear the chat
### Best Practices
1. **Sensitive Data**: Be cautious when using the AI Assistant with topics containing sensitive information
2. **API Key Security**: Never share your OpenAI API key with others
3. **Rate Limiting**: The service implements error handling for rate limits
4. **Offline Operation**: The AI Assistant requires internet connectivity to function
## Technical Details
### Architecture
- **Frontend**: React component with Material-UI styling
- **Service Layer**: Singleton LLM service for API communication
- **API Integration**: OpenAI Chat Completions API (GPT-3.5-turbo by default)
- **Context Generation**: Automatic extraction of topic metadata for relevant queries
### Configuration Options
The LLM service supports:
- **Custom API Endpoints**: Can be configured to use compatible APIs
- **Model Selection**: Defaults to `gpt-3.5-turbo` but can be customized
- **Conversation History**: Automatically manages context (keeps last 10 messages)
- **Timeout Handling**: 30-second timeout for API requests
## Troubleshooting
### "Please configure your OpenAI API key first"
**Solution**: Click the settings icon and add your API key.
### "Invalid API key"
**Solutions**:
- Verify the API key is correct
- Check that your OpenAI account is active
- Ensure you have available API credits
### "Rate limit exceeded"
**Solutions**:
- Wait a few minutes before trying again
- Check your OpenAI API usage dashboard
- Consider upgrading your OpenAI plan if needed
### "Request timeout"
**Solutions**:
- Check your internet connection
- Try asking a simpler question
- Verify OpenAI's service status
## Limitations
- Requires active internet connection
- Needs valid OpenAI API key with available credits
- Responses are limited to 500 tokens for performance
- May not have knowledge of proprietary or custom MQTT implementations
- Beta feature - under active development
## Future Enhancements
Potential improvements being considered:
- Support for additional LLM providers (Anthropic, Azure OpenAI, etc.)
- Ability to save and share helpful conversations
- Integration with automation and scripting features
- Custom prompts and templates for specific use cases
- Offline mode with cached responses for common questions
## Feedback
This is a beta feature. If you encounter issues or have suggestions, please open an issue on the [GitHub repository](https://github.com/thomasnordquist/MQTT-Explorer/issues).
-156
View File
@@ -1,156 +0,0 @@
# Mobile Compatibility Concept
## Overview
This document outlines the mobile compatibility strategy for MQTT Explorer, focusing on providing a good mobile experience without requiring a complete UI rewrite.
## Target Device
**Reference Device:** Google Pixel 6
- Viewport: 412x915 pixels (portrait)
- Typical modern smartphone dimensions
- Good representation of common mobile browsers
## Strategy
### 1. Browser Mode First
Mobile compatibility focuses on the browser mode (`yarn dev:server`) rather than native mobile apps, as:
- Browser mode already supports any device with a modern web browser
- No app store deployment complexities
- Users can access via mobile browser or save as PWA
### 2. Responsive Design Enhancements
Without rewriting the UI, we implement strategic responsive improvements:
#### Viewport Configuration
- Ensure proper viewport meta tag for mobile scaling
- Already present: `<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />`
#### Layout Adaptations
- **Tree Panel**: Make touch-friendly (larger tap targets, better scrolling)
- **Sidebar**: Collapsible by default on mobile, swipe-friendly
- **Chart Panel**: Stack vertically instead of side-by-side
- **Split Panes**: Adjust minimum sizes and default positions for mobile
#### Touch Interactions
- Increase tap target sizes for mobile (minimum 44x44px)
- Improve scrolling performance
- Add touch-friendly gestures where applicable
### 3. Minimal CSS Changes
Use CSS media queries to adapt the UI for mobile viewports:
```css
@media (max-width: 768px) {
/* Mobile-specific overrides */
}
```
Key areas for CSS adjustments:
- Typography sizing (ensure readability on small screens)
- Padding and margins (optimize for touch)
- Button and icon sizes (larger for touch targets)
- Navigation (hamburger menu, collapsible sections)
### 4. Feature Prioritization
On mobile devices, prioritize:
1. **Core Functionality**: View topics, read messages, basic navigation
2. **Search**: Easy topic filtering and search
3. **Connection Management**: Connect/disconnect, basic settings
4. **Publishing**: Simple message publishing
Less critical on mobile (can be de-emphasized):
- Advanced connection settings (can use smaller text/collapse)
- Extensive keyboard shortcuts
- Multi-panel simultaneous viewing
## Implementation Approach
### Phase 1: Foundation (Current)
- Document mobile compatibility concept ✓
- Create mobile demo video showing current experience
- Identify pain points and opportunities
### Phase 2: Quick Wins (Minimal Changes)
- Adjust default split pane positions for mobile
- Increase touch target sizes in critical areas
- Improve sidebar collapse behavior on small screens
- Optimize tree node spacing for touch
### Phase 3: Enhanced Experience (Future)
- Add PWA manifest for "add to home screen"
- Implement swipe gestures
- Optimize connection dialog for mobile
- Add mobile-specific keyboard (numeric for ports, etc.)
## Demo Video
### Purpose
Create a demonstration video showing MQTT Explorer running on a mobile viewport (Pixel 6 dimensions) to:
- Showcase current mobile experience
- Identify UX issues
- Demonstrate the feasibility of mobile usage
- Guide future improvements
### Technical Implementation
- Use Playwright with Chromium in mobile emulation mode
- Viewport size: 412x915 (Pixel 6 portrait)
- Record typical mobile use cases:
- Connecting to broker
- Browsing topic tree (with touch gestures)
- Viewing message details
- Searching topics
- Publishing messages
### Script Location
`src/spec/demoVideoMobile.ts` - Mobile-specific demo video script
## Testing Strategy
### Manual Testing
- Test on real mobile devices (iOS Safari, Android Chrome)
- Use Chrome DevTools device emulation during development
- Verify touch interactions work smoothly
### Automated Testing
- Create mobile-specific UI tests
- Run demo video generation with mobile viewport
- Validate responsive breakpoints
## Future Considerations
### Progressive Web App (PWA)
Add PWA capabilities:
- Service worker for offline support
- App manifest for installability
- App icon and splash screen
### Platform-Specific Optimizations
- iOS: Handle safe areas, notch
- Android: Material Design guidelines
- Dark mode (already supported via theme)
### Performance
- Optimize bundle size for mobile networks
- Implement lazy loading for large topic trees
- Add connection retry logic for unreliable mobile networks
## Metrics for Success
A successful mobile experience should provide:
- ✅ All core features accessible on mobile
- ✅ No horizontal scrolling required
- ✅ Touch targets minimum 44x44px
- ✅ Readable text without zooming
- ✅ Smooth scrolling and interactions
- ✅ Quick load times (<3s on 3G)
## Resources
- [Google Mobile-Friendly Test](https://search.google.com/test/mobile-friendly)
- [Material Design Touch Target Guidelines](https://material.io/design/usability/accessibility.html#layout-typography)
- [MDN Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design)
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
-180
View File
@@ -1,180 +0,0 @@
# Mobile Testing Guide
This document describes how to run and debug mobile UI tests for MQTT Explorer.
## Overview
The mobile tests simulate MQTT Explorer running in a mobile browser (Google Pixel 6 viewport: 412x914px) and generate demo videos showing the mobile user experience.
## Prerequisites
### System Dependencies
```bash
sudo apt-get install -y ffmpeg tmux xvfb x11vnc mosquitto
```
### Node Dependencies
```bash
yarn install
npx playwright install --with-deps chromium
```
## Running Mobile Tests
### 1. Build the Application
```bash
yarn build:server # For browser mode
```
### 2. Run Tests
```bash
./scripts/uiTestsMobile.sh
```
This will:
- Start Xvfb (virtual framebuffer)
- Start mosquitto MQTT broker
- Start MQTT Explorer server in browser mode
- Run Playwright tests with mobile viewport
- Record video of the test session
### 3. Post-Process Video
```bash
./scripts/prepareVideoMobile.sh
```
This converts the raw video to MP4 and GIF formats and creates individual segments for each test scene.
## Output Files
- `ui-test-mobile.mp4` - Full mobile test video (MP4)
- `ui-test-mobile.gif` - Full mobile test video (GIF)
- `segment-mobile-*.gif` - Individual scene segments
- `scenes-mobile.json` - Scene timing metadata
All video files are automatically excluded from git (see `.gitignore`).
## Debugging
### View Test in VNC
During test execution, you can connect with VNC to watch in real-time:
```bash
# Password: bierbier
vncviewer localhost:5900
```
### Common Issues
#### 1. Playwright Browsers Not Installed
**Error:** `Executable doesn't exist at .../chromium_headless_shell-1200/chrome-headless-shell`
**Solution:**
```bash
npx playwright install --with-deps chromium
```
#### 2. Video Encoding Fails
**Error:** `height not divisible by 2`
**Solution:** Ensure viewport height is even. Mobile viewport is set to 412x914 (not 915).
#### 3. Elements Outside Viewport
**Error:** `element is outside of the viewport`
**Solution:** The fix adds `scrollIntoViewIfNeeded()` before clicking elements. For modal/dialog elements that intercept clicks, use `force: true`.
#### 4. MQTT Broker Already Running
**Error:** `Address already in use` on port 1883
**Solution:** Kill existing mosquitto process:
```bash
pkill mosquitto
```
## Mobile UI Enhancements
The tests revealed several mobile UI improvements that were implemented:
### 1. Connection Dialog Responsiveness
Added responsive CSS to `ConnectionSetup.tsx`:
- Mobile viewports use 95vw width and 85vh height
- Enabled scrolling on the right panel
- Hide profile list on mobile to save space
### 2. Click Handling
Enhanced `clickOn` helper in `util/index.ts`:
- Added `scrollIntoViewIfNeeded()` to ensure elements are in viewport
- Support for `force: true` to bypass overlay elements
### 3. Tree Node Expansion
Updated `expandTopic` helper:
- Use `force: true` for tree clicks to bypass accordion overlays
- Better handling of nested topic expansion
## Test Scenes
The mobile demo includes these scenes:
1. **mobile_intro** - Introduction screen
2. **mobile_connect** - Connect to MQTT broker
3. **mobile_browse_topics** - Browse topic tree
4. **mobile_search** - Search and filter topics
5. **mobile_view_message** - View message details
6. **mobile_json_view** - JSON formatting display
7. **mobile_clipboard** - Copy operations
8. **mobile_plots** - Numeric data visualization
9. **mobile_menu** - Settings and menu
10. **mobile_end** - Conclusion screen
## CI Integration
Mobile tests run in the `demo-video-mobile` job in `.github/workflows/tests.yml`:
```yaml
demo-video-mobile:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
steps:
- name: Generate Mobile Demo Video
run: ./scripts/uiTestsMobile.sh
- name: Post-processing
run: ./scripts/prepareVideoMobile.sh
```
Videos are uploaded to S3 and linked in PR comments.
## Technical Notes
### Viewport Configuration
- **Width:** 412px (Pixel 6)
- **Height:** 914px (must be even for h264 encoding)
- **Device Scale Factor:** 2.625
- **Mobile Mode:** Enabled with touch events
### Video Recording
- Raw video: YUV420P format
- Frame rate: 20 fps
- Recording tool: ffmpeg via tmux
### Post-Processing
- MP4 encoding: h264 codec
- GIF palette: 256 colors optimized per segment
- Segment creation: Based on scene timing in `scenes-mobile.json`
## Future Improvements
Potential areas for enhancement:
1. **Touch Gestures** - Add swipe and pinch interactions
2. **Performance** - Optimize for slower mobile networks
3. **Accessibility** - Larger touch targets, better contrast
4. **PWA Support** - Add manifest for "add to home screen"
5. **Orientation** - Test landscape mode
## References
- [MOBILE_COMPATIBILITY.md](./MOBILE_COMPATIBILITY.md) - Mobile compatibility strategy
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
- [Material-UI Responsive Design](https://mui.com/material-ui/customization/breakpoints/)
+10 -116
View File
@@ -54,27 +54,6 @@ yarn start:server
Then open your browser to `http://localhost:3000`. For more details, see [BROWSER_MODE.md](BROWSER_MODE.md).
### Docker (Browser Mode)
[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/thomasnordquist/MQTT-Explorer/master/docker-compose.yml)
Run MQTT Explorer in a Docker container:
```bash
docker run -d \
-p 3000:3000 \
-e MQTT_EXPLORER_USERNAME=admin \
-e MQTT_EXPLORER_PASSWORD=your_secure_password \
-v mqtt-explorer-data:/app/data \
ghcr.io/thomasnordquist/mqtt-explorer:latest
```
**Supports multiple platforms**: amd64, arm64 (Raspberry Pi 3/4/5), arm/v7 (Raspberry Pi 2/3).
**Enterprise integration**: Set `MQTT_EXPLORER_SKIP_AUTH=true` to disable built-in authentication when deploying behind a secure authentication proxy (e.g., OAuth2 Proxy, SSO).
For complete Docker documentation including authentication options, deployment examples, and security best practices, see [DOCKER.md](DOCKER.md).
## Develop
### Desktop Application
@@ -99,55 +78,14 @@ yarn dev:server
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management, `src` contains all the electron bindings. [mqttjs](https://github.com/mqttjs/MQTT.js) is used to facilitate communication to MQTT brokers.
For information on styling conventions and visual design patterns, see [STYLING.md](STYLING.md).
## Automated Tests
MQTT Explorer uses multiple test suites to ensure reliability and quality:
To achieve a reliable product automated tests run regularly on CI.
### Unit Tests
**App tests** - Frontend component and logic tests:
```bash
yarn test:app
```
**Backend tests** - Data model and business logic tests:
```bash
yarn test:backend
```
**Run all unit tests**:
```bash
yarn test
```
### Integration & UI Tests
**UI test suite** - Independent, deterministic browser tests:
```bash
yarn build
yarn test:ui
```
**Demo video generation** - UI test recording for documentation:
```bash
yarn build
yarn test:demo-video
```
Note: Requires Xvfb, mosquitto broker, tmux, and ffmpeg. For full video recording setup, use `./scripts/uiTests.sh`.
**MCP introspection tests** - Model Context Protocol validation:
```bash
yarn build
yarn test:mcp
```
**Run all tests** (unit tests + demo video):
```bash
yarn build
yarn test:all
```
- **Data model tests**: `yarn test:backend`
- **App tests**: `yarn test:app`
- **UI test suite**: `yarn test:ui` (independent, deterministic tests)
- **Demo video**: `yarn ui-test` (UI test recording for documentation)
### Run UI Test Suite
@@ -166,55 +104,13 @@ See [docs/UI-TEST-SUITE.md](docs/UI-TEST-SUITE.md) for more details.
### Run Demo Video Generation
The demo video is used for documentation and showcases key features. It requires additional dependencies:
A [mosquitto](https://mosquitto.org/) MQTT broker is required to generate the demo video.
```bash
yarn build
yarn test:demo-video
yarn ui-test
```
**Requirements:**
- mosquitto MQTT broker
- Xvfb (virtual framebuffer)
- tmux (terminal multiplexer)
- ffmpeg (video encoding)
**For full video recording with post-processing:**
```bash
yarn build
./scripts/uiTests.sh
```
This script handles Xvfb setup, mosquitto startup, video recording, and cleanup.
### Mobile Demo Video
A mobile-focused demo video showcases MQTT Explorer in a mobile viewport (Pixel 6: 412x915px):
```bash
yarn build
yarn test:demo-video:mobile
```
Or with full recording setup:
```bash
yarn build
./scripts/uiTestsMobile.sh
```
This demonstrates the mobile compatibility features and responsive design improvements. See [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md) for the mobile strategy and implementation details.
## Mobile Compatibility
MQTT Explorer supports mobile devices through its browser mode with responsive design enhancements:
- **Target Device**: Google Pixel 6 (412x915px viewport)
- **Touch-Friendly UI**: Minimum 44px tap targets for better mobile UX
- **Responsive Layout**: Sidebar and panels adapt to mobile viewports
- **Browser Mode**: Access via mobile browser or install as PWA
For the complete mobile compatibility concept, implementation phases, and future roadmap, see [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md).
## Create a release
Create a PR to `release` branch.
@@ -253,9 +149,7 @@ The readme will be generated from the docs.
## License
![CC BY-SA 4.0](https://img.shields.io/badge/License-CC%20BY--SA%204.0-blue.svg)
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
![CC-BY-Nc 4.0](https://img.shields.io/badge/License-CC%20BY--NC%204.0-blue.svg)
[CC-BY-Nc 4.0](https://creativecommons.org/licenses/by-nC/4.0/)
**Special requirement:** When distributing, the attribution and donation page may not be altered or made less accessible without explicit approval.
The license allows for anyone to adapt, share, and redistribute the material, as long as they give appropriate credit and distribute any derivative works under the same license.
The license allows for anyone to adapt, share, and redistribute the material, as long as it is non-commercial.
-212
View File
@@ -1,212 +0,0 @@
# MQTT Explorer Styling Conventions
This document outlines the styling conventions used in MQTT Explorer for visual consistency and maintainable code.
## UI Framework
Material-UI (MUI) v7 with JSS styling via `withStyles` HOC.
**Stack:**
- `@mui/material` (v7) - Core components and theming
- `@mui/icons-material` (v7) - Icons
- `@mui/styles` (v6) - JSS styling with `withStyles`
- `@emotion/react` & `@emotion/styled` - CSS-in-JS foundation
## Theming
**Location:** `app/src/theme.ts`
**Configuration:**
- Light and dark modes supported
- Primary color: `#335C67` (teal/blue-green)
- Secondary: Material-UI `amber` palette
- Base typography: `0.9rem`, `userSelect: 'none'`
**Application:**
```typescript
<ThemeProvider theme={theme}>
<LegacyThemeProvider theme={theme}>
<App />
</LegacyThemeProvider>
</ThemeProvider>
```
## Colors
**Access theme colors:**
```typescript
backgroundColor: theme.palette.background.default
color: theme.palette.text.primary
borderColor: theme.palette.divider
```
**Material-UI palettes:**
```typescript
import { blueGrey, amber, green, red, orange } from '@mui/material/colors'
backgroundColor: blueGrey[100] // Light shade
backgroundColor: blueGrey[700] // Dark shade
```
**Theme-conditional:**
```typescript
const color = theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main
```
**Code editor colors:** Defined in `app/src/components/Sidebar/CodeBlockColors.ts`
## Typography
**Variants:**
```typescript
<Typography variant="h6">Heading</Typography>
<Typography variant="body1">Body text</Typography>
<Typography variant="caption">Caption</Typography>
```
**Font sizes:**
```typescript
fontSize: theme.typography.pxToRem(15)
```
**Monospace:** `"12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace"`
## Spacing
**8px grid system:**
```typescript
margin: theme.spacing(1) // 8px
padding: theme.spacing(2) // 16px
marginLeft: theme.spacing(1.5) // 12px (tree indentation)
```
**Border radius:**
```typescript
borderRadius: theme.shape.borderRadius // 4px default
```
## Component Styling
**Primary approach - withStyles HOC:**
```typescript
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
const styles = (theme: Theme) => ({
root: {
backgroundColor: theme.palette.background.default,
padding: theme.spacing(2),
},
})
export default withStyles(styles)(MyComponent)
```
**Type assertions:**
```typescript
display: 'block' as 'block'
whiteSpace: 'nowrap' as 'nowrap'
overflow: 'hidden' as 'hidden'
```
**Responsive:**
```typescript
[theme.breakpoints.up(750)]: {
display: 'block',
}
```
**sx prop (simple cases):**
```typescript
<Button sx={{ color: 'primary.contrastText' }}>Text</Button>
```
## Animations
**CSS animations:**
```typescript
animation: 'updateLight 0.5s'
```
**Theme transitions:**
```typescript
transition: theme.transitions.create('transform', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
})
```
## Interactive States
**Hover:**
```typescript
'&:hover': {
backgroundColor: theme.palette.mode === 'light'
? blueGrey[100]
: theme.palette.primary.light,
}
```
**Selection:**
```typescript
selected: {
backgroundColor: (theme.palette.mode === 'light'
? blueGrey[300]
: theme.palette.primary.main) + ' !important',
}
```
## Common Patterns
**Tree nodes:**
```typescript
node: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
subnodes: {
marginLeft: theme.spacing(1.5),
}
```
**Buttons:**
```typescript
<Button variant="contained" color="primary">Submit</Button>
<Button variant="outlined" color="primary">Cancel</Button>
<Button>Learn More</Button>
```
**Icons:**
```typescript
<Icon fontSize="inherit" />
<Icon style={{ fontSize: '16px' }} />
```
## Best Practices
**DO:**
✅ Use theme variables (`theme.palette.*`, `theme.spacing()`, `theme.typography.*`)
✅ Use `withStyles` HOC for component styles
✅ Use `theme.palette.mode` for light/dark conditional styling
✅ Import Material-UI color palettes for extended colors
✅ Keep styles co-located with components
**DON'T:**
❌ Hardcode colors or spacing values
❌ Create global CSS files
❌ Duplicate style definitions
❌ Use inline styles for complex patterns
## Resources
- [Material-UI Documentation](https://mui.com/material-ui/getting-started/)
- [Color System](https://mui.com/material-ui/customization/color/)
- [Theming Guide](https://mui.com/material-ui/customization/theming/)
**Testing:** Verify in both light/dark themes, check responsive behavior, ensure accessibility.
## Related
- [README.md](./Readme.md) - Project overview
- [BROWSER_MODE.md](./BROWSER_MODE.md) - Browser mode
- [.github/copilot-instructions.md](./.github/copilot-instructions.md) - Copilot instructions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

-309
View File
@@ -1,309 +0,0 @@
# React Component Testing Guide
This guide explains how to write tests for React components in the MQTT-Explorer project using the generic testing utilities.
## Overview
We use the following testing stack:
- **Mocha** - Test framework
- **Chai** - Assertion library
- **React Testing Library** - React component testing utilities
- **JSDOM** - DOM implementation for Node.js
- **Custom Test Utilities** - Located in `src/utils/spec/testUtils.tsx`
## Quick Start
### 1. Create a Test File
Test files should be placed next to the component they test with the `.spec.tsx` extension:
```
src/components/MyComponent/
├── MyComponent.tsx
└── MyComponent.spec.tsx
```
### 2. Basic Test Structure
```typescript
import React from 'react'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import MyComponent from './MyComponent'
import { renderWithProviders } from '../../utils/spec/testUtils'
describe('MyComponent', () => {
it('should render correctly', () => {
const { container } = renderWithProviders(<MyComponent />)
expect(container).to.exist
})
})
```
## Using Test Utilities
### `renderWithProviders`
This function wraps your component with necessary providers (Theme, Redux).
```typescript
import { renderWithProviders } from '../../utils/spec/testUtils'
// Render with theme only (default)
const { container } = renderWithProviders(<MyComponent />, { withTheme: true })
// Render with both theme and Redux
const { container } = renderWithProviders(<MyComponent />, {
withTheme: true,
withRedux: true
})
// Render with custom theme
import { createTheme } from '@mui/material/styles'
const darkTheme = createTheme({ palette: { mode: 'dark' } })
const { container } = renderWithProviders(<MyComponent />, {
theme: darkTheme
})
// Render with custom Redux store
import { configureStore } from '@reduxjs/toolkit'
const customStore = configureStore({ /* ... */ })
const { container } = renderWithProviders(<MyComponent />, {
store: customStore,
withRedux: true
})
```
### `createMockChartData`
Helper function to generate mock chart data:
```typescript
import { createMockChartData } from '../../utils/spec/testUtils'
// Create 10 data points (default)
const data = createMockChartData()
// Create specific number of points
const data = createMockChartData(50)
```
### Global Mocks
The test utilities automatically set up global mocks:
- **ResizeObserver** - Mocked for components using `react-resize-detector`
## Common Testing Patterns
### Testing Rendering
```typescript
it('should render without crashing', () => {
const { container } = renderWithProviders(<MyComponent />)
expect(container).to.exist
})
it('should render specific element', () => {
const { container } = renderWithProviders(<MyComponent />)
const element = container.querySelector('.my-class')
expect(element).to.exist
})
```
### Testing Props
```typescript
it('should accept all valid props', () => {
const props = {
title: 'Test',
value: 123,
onchange: () => {},
}
const { container } = renderWithProviders(<MyComponent {...props} />)
expect(container).to.exist
})
```
### Testing User Interactions
```typescript
import { userEvent } from '../../utils/spec/testUtils'
it('should handle click events', async () => {
const { container } = renderWithProviders(<MyComponent />)
const button = container.querySelector('button')
if (button) {
await userEvent.click(button)
// Assert expected behavior
}
})
```
### Testing Different States
```typescript
it('should render empty state', () => {
const { container } = renderWithProviders(<MyComponent data={[]} />)
// Assert empty state
})
it('should render with data', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<MyComponent data={data} />)
// Assert data is rendered
})
```
### Testing SVG Components
```typescript
it('should render SVG elements', () => {
const { container } = renderWithProviders(<ChartComponent />)
const svg = container.querySelector('svg')
expect(svg).to.exist
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(5)
})
```
### Testing Edge Cases
```typescript
describe('Edge Cases', () => {
it('should handle negative values', () => {
const data = [{ x: 1, y: -10 }, { x: 2, y: -20 }]
const { container } = renderWithProviders(<MyComponent data={data} />)
expect(container.querySelector('svg')).to.exist
})
it('should handle empty arrays', () => {
const { container } = renderWithProviders(<MyComponent data={[]} />)
expect(container).to.exist
})
it('should handle very large numbers', () => {
const data = [{ x: 1, y: 1000000 }]
const { container } = renderWithProviders(<MyComponent data={data} />)
expect(container).to.exist
})
})
```
## Running Tests
### Run all tests
```bash
yarn test
```
### Run specific test file
```bash
npx mocha --require tsx --require source-map-support/register "src/components/MyComponent/MyComponent.spec.tsx"
```
### Run tests in watch mode
```bash
npx mocha --require tsx --require source-map-support/register --watch "src/**/*.spec.{ts,tsx}"
```
## Best Practices
1. **Test Behavior, Not Implementation**
- Focus on what the component does, not how it does it
- Test user-facing behavior and output
2. **Use Descriptive Test Names**
```typescript
// Good
it('should render error message when validation fails')
// Bad
it('test1')
```
3. **Group Related Tests**
```typescript
describe('MyComponent', () => {
describe('Rendering', () => {
it('should render correctly')
it('should render with props')
})
describe('Interactions', () => {
it('should handle clicks')
it('should handle keyboard input')
})
})
```
4. **Keep Tests Independent**
- Each test should be able to run in isolation
- Don't rely on test execution order
- Clean up after each test if needed
5. **Test Edge Cases**
- Empty data
- Null/undefined values
- Very large/small numbers
- Negative values
- Single item arrays
6. **Use Chai Assertions**
```typescript
expect(value).to.exist
expect(value).to.be.true
expect(value).to.equal(expected)
expect(array).to.have.length(5)
expect(number).to.be.greaterThan(0)
```
## Example: Complete Test Suite
See `src/components/Chart/Chart.spec.tsx` for a comprehensive example that demonstrates:
- Multiple test groups (Rendering, Data Visualization, Edge Cases, etc.)
- Testing with different props and configurations
- Testing SVG elements
- Testing theme integration
- Performance testing
- Edge case coverage
## Troubleshooting
### "ResizeObserver is not defined"
This is automatically mocked by the test utilities. Make sure you're importing from `testUtils.tsx`.
### "Cannot find module"
Check your import paths. Remember to use relative paths from the test file.
### "Window is not defined"
Make sure `jsdom-global/register` is imported in testUtils.tsx.
### Tests timing out
Increase the timeout in your test:
```typescript
it('should complete async operation', function() {
this.timeout(5000) // 5 seconds
// test code
})
```
## Adding New Test Utilities
To add new helper functions, update `src/utils/spec/testUtils.tsx`:
```typescript
export function myNewHelper() {
// Helper implementation
}
```
Then use it in your tests:
```typescript
import { myNewHelper } from '../../utils/spec/testUtils'
```
-43
View File
@@ -18,49 +18,6 @@
outline: none;
}
/* Mobile-specific responsive styles */
@media (max-width: 768px) {
/* Increase touch target sizes for better mobile UX */
button {
min-height: 44px !important;
min-width: 44px !important;
}
/* Make icons larger on mobile */
svg {
font-size: 1.5rem !important;
}
/* Improve tree node tap targets */
[data-testid="tree-node"] {
min-height: 44px !important;
padding: 8px 12px !important;
}
/* Better mobile typography */
body {
font-size: 16px !important;
}
/* Prevent text selection on mobile taps - applied to interactive elements */
button, a, [role="button"], [data-testid] {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
/* Improve scrolling performance for scrollable containers */
[style*="overflow"], .MuiDrawer-root, [data-testid="tree-container"] {
-webkit-overflow-scrolling: touch;
}
/* Make resizers more visible on mobile */
.Resizer.vertical::before,
.Resizer.horizontal::before {
font-size: 1.5rem !important;
opacity: 0.8 !important;
}
}
@keyframes updateDark {
0% {
background-color: none;
+15 -25
View File
@@ -6,34 +6,29 @@
"scripts": {
"build": "webpack --mode production",
"dev": "node_modules/.bin/webpack-dev-server --mode development --progress",
"test": "mocha --require tsx --require source-map-support/register --recursive 'src/*/**/*.spec.{ts,tsx}'",
"mochatest": "mocha --require tsx --require source-map-support/register --recursive 'src/*/**/*.spec.{ts,tsx}'"
"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"
},
"author": "",
"license": "CC-BY-SA-4.0",
"license": "CC-BY-ND-4.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^7.3.6",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.18.0",
"@mui/lab": "^5.0.0-alpha.177",
"@mui/material": "^5.18.0",
"@mui/styles": "^6.4.8",
"@react-spring/web": "^9.7.5",
"@types/react-transition-group": "^4.4.11",
"@visx/axis": "^3.10.1",
"@visx/grid": "^3.5.0",
"@visx/tooltip": "^3.3.0",
"@visx/xychart": "^3.10.2",
"ace-builds": "^1.4.11",
"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": "^8.0.3",
"diff": "^7.0.0",
"dot-prop": "^5.3.0",
"events": "^3.3.0",
"get-value": "^3.0.1",
@@ -49,13 +44,14 @@
"parse-duration": "^0.1.1",
"path-browserify": "^1.0.1",
"prismjs": "^1.29.0",
"react": "^19.2.3",
"react-ace": "^14.0.1",
"react-dom": "^19.2.3",
"react": "^18.3.1",
"react-ace": "^12.0.0",
"react-dom": "^18.3.1",
"react-redux": "^9.2.0",
"react-resize-detector": "^11.0.1",
"react-split-pane": "^0.1.92",
"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",
@@ -66,18 +62,14 @@
},
"devDependencies": {
"@babel/runtime": "^7.28.4",
"@reduxjs/toolkit": "2.5.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@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": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-redux": "^7.1.34",
"@types/react-resize-detector": "^4.0.3",
"@types/sha1": "^1.1.1",
@@ -89,9 +81,7 @@
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"jsdom": "25.0.1",
"jsdom-global": "3.0.2",
"lodash": "^4.17.23",
"lodash": "^4.17.21",
"mocha": "^10.8.2",
"moment": "^2.30.1",
"node-loader": "^2.0.0",
+1 -1
View File
@@ -9,7 +9,7 @@ import { globalActions } from '.'
import { resetStore as resetTreeStore, showTree } from './Tree'
import { showError } from './Global'
import { TopicViewModel } from '../model/TopicViewModel'
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../eventBus'
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../events'
export const connect =
(options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
+7 -4
View File
@@ -13,7 +13,7 @@ import * as path from 'path'
import { ActionTypes, Action } from '../reducers/ConnectionManager'
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
import { connectionsMigrator } from './migrations/Connection'
import { rendererRpc, readFromFile } from '../eventBus'
import { rendererRpc, readFromFile } from '../../../events'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
export interface ConnectionDictionary {
@@ -46,9 +46,6 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
const firstKey = Object.keys(connections)[0]
if (firstKey) {
dispatch(selectConnection(firstKey))
} else {
// No connections exist - create a default one
dispatch(createConnection())
}
}
@@ -150,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)
-11
View File
@@ -21,12 +21,6 @@ export const toggleSettingsVisibility = () => (dispatch: Dispatch<any>) => {
})
}
export const toggleAboutDialogVisibility = () => (dispatch: Dispatch<any>) => {
dispatch({
type: ActionTypes.toggleAboutDialogVisibility,
})
}
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
return new Promise(resolve => {
const confirmationRequest = {
@@ -53,8 +47,3 @@ export const removeConfirmationRequest = (confirmationRequest: ConfirmationReque
})
})
}
export const setMobileTab = (tabIndex: number) => ({
mobileTab: tabIndex,
type: ActionTypes.setMobileTab,
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { Action, ActionTypes } from '../reducers/Publish'
import { AppState } from '../reducers'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
import { Dispatch } from 'redux'
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../eventBus'
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../../../events'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
import { showError } from './Global'
import { Base64 } from 'js-base64'
-2
View File
@@ -12,8 +12,6 @@ export { clearTopic } from './clearTopic'
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
export { setMobileTab } from './Global'
export const selectTopic =
(topic: q.TreeNode<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
debouncedSelectTopic(topic, dispatch, getState)
+1 -1
View File
@@ -1,7 +1,7 @@
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { makePublishEvent, rendererEvents } from '../eventBus'
import { makePublishEvent, rendererEvents } from '../../../events'
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
import { globalActions } from '.'
+18 -1
View File
@@ -68,6 +68,17 @@ let migrations: Migration[] = [
}
},
},
// Add order field for connection ordering
{
from: 1,
apply: (connection: ConnectionOptions): ConnectionOptions => {
if (connection.order !== undefined) {
return connection
}
// Order will be assigned during migration based on current position
return connection
},
},
]
const connectionMigrator = new ConfigMigrator(migrations)
@@ -80,8 +91,14 @@ function isMigrationNecessary(connections: ConnectionDictionary): boolean {
function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary {
let newConnectionDictionary: ConnectionDictionary = {}
Object.keys(connections).forEach(key => {
const connectionKeys = Object.keys(connections)
connectionKeys.forEach((key, index) => {
let newConnection = connectionMigrator.applyMigrations(connections[key]) as any
// If the migration didn't assign an order, assign one based on current position
if (newConnection.order === undefined) {
newConnection.order = index
}
newConnectionDictionary[newConnection.id] = newConnection
})
-48
View File
@@ -1,48 +0,0 @@
// Auto-connect handler for browser mode
// This file is loaded early in the app initialization to handle server-initiated auto-connect
import { store } from './store'
import * as q from '../../backend/src/Model'
import { TopicViewModel } from './model/TopicViewModel'
import { showTree } from './actions/Tree'
import { connecting, connected } from './actions/Connection'
import { makeConnectionStateEvent, rendererEvents } from './eventBus'
import { DataSourceState } from '../../backend/src/DataSource'
// Listen for auto-connect-initiated event from server
if (typeof window !== 'undefined') {
window.addEventListener('mqtt-auto-connect-initiated', ((event: CustomEvent) => {
const { connectionId } = event.detail
console.log('Auto-connect initiated from server, connectionId:', connectionId)
// Dispatch connecting action
store.dispatch(connecting(connectionId) as any)
console.log('Dispatched connecting action')
// Subscribe to connection state events
const stateEvent = makeConnectionStateEvent(connectionId)
console.log('Subscribing to connection state event:', stateEvent)
rendererEvents.subscribe(stateEvent, (dataSourceState: DataSourceState) => {
console.log('Auto-connect state update:', JSON.stringify(dataSourceState, null, 2))
if (dataSourceState.connected) {
console.log('Auto-connect: connection established!')
const state = store.getState()
const didReconnect = Boolean(state.connection.tree)
if (!didReconnect) {
// Create tree and update with connection
console.log('Creating tree for connection:', connectionId)
const tree = new q.Tree<TopicViewModel>()
tree.updateWithConnection(rendererEvents, connectionId)
store.dispatch(showTree(tree) as any)
store.dispatch(connected(tree, 'auto-connect') as any)
console.log('Auto-connect successful, tree created and dispatched')
}
} else if (dataSourceState.error) {
console.error('Auto-connect error:', dataSourceState.error)
}
})
console.log('Auto-connect handler setup complete')
}) as EventListener)
}
-36
View File
@@ -60,42 +60,6 @@ socket.on('connect', () => {
}
})
// Listen for auth-status from server (sent on connection)
socket.on('auth-status', (data: { authDisabled: boolean }) => {
console.log('Auth status received from server:', data)
// Dispatch custom event with auth status
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auth-status', {
detail: { authDisabled: data.authDisabled }
}))
}
})
// Listen for auto-connect configuration from server
socket.on('auto-connect-config', (config: any) => {
console.log('Auto-connect configuration received from server')
// Dispatch custom event with auto-connect config
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-config', {
detail: config
}))
}
})
// Listen for auto-connect-initiated event from server
socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
console.log('Auto-connect initiated by server, connectionId:', data.connectionId)
// Dispatch custom event to trigger connection flow
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-initiated', {
detail: data
}))
}
})
/**
* Update socket authentication credentials and attempt to reconnect
* @param newUsername New username
-133
View File
@@ -1,133 +0,0 @@
import { expect } from 'chai'
import 'mocha'
import * as fs from 'fs'
import * as path from 'path'
/**
* AboutDialog License Compliance Tests
*
* These tests verify that the About dialog properly displays required
* attribution information as mandated by the CC-BY-ND-4.0 license.
*
* CC-BY-ND-4.0 (Creative Commons Attribution-NoDerivatives 4.0 International):
* - BY (Attribution): Must credit the original author (Thomas Nordquist)
* - ND (NoDerivatives): Cannot create derivative works without permission
* - Requires: Author name, license notice, and LICENSE NOTICE comment
*/
describe('AboutDialog License Compliance', () => {
const aboutDialogPath = path.join(__dirname, 'AboutDialog.tsx')
let aboutDialogContent: string
before(() => {
aboutDialogContent = fs.readFileSync(aboutDialogPath, 'utf-8')
})
it('should contain the license notice in the component file', () => {
expect(aboutDialogContent).to.include('LICENSE NOTICE')
expect(aboutDialogContent).to.include('CC-BY-ND-4.0')
})
it('should display author attribution (Thomas Nordquist)', () => {
// Verify the author is displayed in the component
expect(aboutDialogContent).to.match(/Author.*Thomas Nordquist/)
})
it('should display CC-BY-ND-4.0 license', () => {
// Verify the license is displayed in the component
expect(aboutDialogContent).to.match(/License.*CC-BY-ND-4.0/)
})
it('should have data-testid attributes for license verification', () => {
// These attributes allow automated testing of the rendered component
expect(aboutDialogContent).to.include('data-testid="about-author"')
expect(aboutDialogContent).to.include('data-testid="about-license"')
})
describe('License Violation Detection', () => {
it('removing author attribution violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 Attribution (BY) requirement:
// Must credit the original author "Thomas Nordquist"
const hasAuthor = aboutDialogContent.includes('Thomas Nordquist')
if (!hasAuthor) {
throw new Error(
'LICENSE VIOLATION: Author attribution "Thomas Nordquist" is missing. ' +
'This violates the CC-BY-ND-4.0 Attribution (BY) requirement. ' +
'The author must be properly credited in the About dialog.'
)
}
expect(hasAuthor).to.be.true
})
it('removing license notice violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires the license identifier to be displayed
const hasLicense = aboutDialogContent.includes('CC-BY-ND-4.0')
if (!hasLicense) {
throw new Error(
'LICENSE VIOLATION: License notice "CC-BY-ND-4.0" is missing. ' +
'This violates CC-BY-ND-4.0 license notice requirements. ' +
'The license identifier must be displayed in the About dialog.'
)
}
expect(hasLicense).to.be.true
})
it('removing LICENSE NOTICE comment violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires attribution notice in source code
const hasLicenseNotice = aboutDialogContent.includes('LICENSE NOTICE')
if (!hasLicenseNotice) {
throw new Error(
'LICENSE VIOLATION: LICENSE NOTICE comment is missing from source code. ' +
'This violates CC-BY-ND-4.0 source code attribution requirements. ' +
'The LICENSE NOTICE comment must be retained in the component source.'
)
}
expect(hasLicenseNotice).to.be.true
})
})
})
/**
* AboutDialog Functionality Tests
*
* These tests verify that the About dialog is accessible and functional.
*/
describe('AboutDialog Accessibility', () => {
const detailsTabPath = path.join(__dirname, 'Sidebar', 'DetailsTab.tsx')
const appPath = path.join(__dirname, 'App.tsx')
it('should be accessible from the DetailsTab component', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the About button exists in DetailsTab
expect(detailsTabContent).to.include('About')
// Verify it triggers the toggle action
expect(detailsTabContent).to.include('toggleAboutDialogVisibility')
})
it('should be integrated in the App component', () => {
const appContent = fs.readFileSync(appPath, 'utf-8')
// Verify AboutDialog is imported
expect(appContent).to.include('AboutDialog')
// Verify it's rendered with state
expect(appContent).to.include('aboutDialogVisible')
})
it('should have About button with Info icon in DetailsTab', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the button text
expect(detailsTabContent).to.include('About MQTT Explorer')
// Verify Info icon is used
expect(detailsTabContent).to.match(/import.*Info.*from.*@mui\/icons-material/)
})
})
-136
View File
@@ -1,136 +0,0 @@
import React from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Link,
Avatar,
Box,
Divider,
} from '@mui/material'
import { rendererRpc, getAppVersion } from '../../../events'
import FavoriteIcon from '@mui/icons-material/Favorite'
// Fallback version if RPC call fails (e.g., in browser mode during initialization)
const FALLBACK_VERSION = '0.4.0-beta.5'
interface AboutDialogProps {
open: boolean
onClose: () => void
}
/**
* About Dialog Component
*
* This component displays application information including version, author, and license.
*
* LICENSE NOTICE (CC-BY-ND-4.0):
* This component is licensed under Creative Commons Attribution-NoDerivatives 4.0 International.
*
* REQUIRED ATTRIBUTION:
* - Author: Thomas Nordquist
* - License: CC-BY-ND-4.0
*
* RESTRICTIONS:
* - BY (Attribution): You must give appropriate credit to the author
* - ND (NoDerivatives): You may not create derivative works without permission
*
* Removing or modifying this attribution violates the license terms.
* For full license text: https://creativecommons.org/licenses/by-nd/4.0/legalcode
*/
export function AboutDialog(props: AboutDialogProps) {
const [version, setVersion] = React.useState<string>(FALLBACK_VERSION)
React.useEffect(() => {
// Fetch version from backend
rendererRpc
.call(getAppVersion, undefined, 5000)
.then(v => setVersion(v))
.catch(() => {
// Fallback to hardcoded version if RPC fails
console.warn('Failed to fetch app version, using fallback')
})
}, [])
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>About MQTT Explorer</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
<strong>Version:</strong> {version}
</Typography>
<Typography variant="body1" gutterBottom data-testid="about-license">
<strong>License:</strong> CC-BY-ND-4.0
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Description:</strong> Explore your message queues
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }} data-testid="about-author">
<Avatar
src="https://github.com/thomasnordquist.png"
alt="Thomas Nordquist"
sx={{ width: 56, height: 56 }}
/>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
Thomas Nordquist
</Typography>
<Link
href="https://github.com/thomasnordquist"
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'block', fontSize: '0.875rem' }}
>
@thomasnordquist
</Link>
<Link
href="https://paypal.me/ThomasNordquist"
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
fontSize: '0.875rem',
mt: 0.5,
}}
>
<FavoriteIcon sx={{ fontSize: '1rem', color: 'error.main' }} />
Support via PayPal
</Link>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body1" gutterBottom>
<strong>Homepage:</strong>{' '}
<Link href="https://thomasnordquist.github.io/MQTT-Explorer/" target="_blank" rel="noopener noreferrer">
https://thomasnordquist.github.io/MQTT-Explorer/
</Link>
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Bug Report:</strong>{' '}
<Link
href="https://github.com/thomasnordquist/MQTT-Explorer/issues"
target="_blank"
rel="noopener noreferrer"
>
https://github.com/thomasnordquist/MQTT-Explorer/issues
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} color="primary" variant="contained">
Close
</Button>
</DialogActions>
</Dialog>
)
}
-7
View File
@@ -6,7 +6,6 @@ import Notification from './Layout/Notification'
import React from 'react'
import TitleBar from './Layout/TitleBar'
import UpdateNotifier from './UpdateNotifier'
import { AboutDialog } from './AboutDialog'
import { AppState } from '../reducers'
import { bindActionCreators } from 'redux'
import { ConfirmationRequest } from '../reducers/Global'
@@ -29,7 +28,6 @@ interface Props {
settingsActions: typeof settingsActions
launching: boolean
confirmationRequests: Array<ConfirmationRequest>
aboutDialogVisible: boolean
}
class App extends React.PureComponent<Props, {}> {
@@ -77,10 +75,6 @@ class App extends React.PureComponent<Props, {}> {
<CssBaseline />
<ErrorBoundary>
<ConfirmationDialog confirmationRequests={this.props.confirmationRequests} />
<AboutDialog
open={this.props.aboutDialogVisible}
onClose={() => this.props.actions.toggleAboutDialogVisibility()}
/>
{this.renderNotification()}
<React.Suspense fallback={<div></div>}>
<Settings {...anyProps} />
@@ -164,7 +158,6 @@ const mapStateToProps = (state: AppState) => {
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
launching: state.globalState.get('launching'),
confirmationRequests: state.globalState.get('confirmationRequests'),
aboutDialogVisible: state.globalState.get('aboutDialogVisible'),
}
}
+15 -47
View File
@@ -2,7 +2,6 @@ import * as React from 'react'
import { LoginDialog } from './LoginDialog'
import { updateSocketAuth, connectSocket } from '../browserEventBus'
import { isBrowserMode } from '../utils/browserMode'
import { AuthContext } from '../contexts/AuthContext'
interface BrowserAuthWrapperProps {
children: React.ReactNode
@@ -11,48 +10,17 @@ interface BrowserAuthWrapperProps {
export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const [isAuthenticated, setIsAuthenticated] = React.useState(false)
const [loginError, setLoginError] = React.useState<string | undefined>()
const [showLogin, setShowLogin] = React.useState(false)
const [showLogin, setShowLogin] = React.useState(isBrowserMode) // Show login initially in browser mode
const [waitTimeSeconds, setWaitTimeSeconds] = React.useState<number | undefined>()
const [isConnecting, setIsConnecting] = React.useState(false)
const [authCheckComplete, setAuthCheckComplete] = React.useState(false)
const [authDisabled, setAuthDisabled] = React.useState(false)
React.useEffect(() => {
if (!isBrowserMode) {
// Not in browser mode, skip authentication
setIsAuthenticated(true)
setAuthCheckComplete(true)
return
}
// Listen for auth status from socket connection
const handleAuthStatus = (event: CustomEvent) => {
const { authDisabled } = event.detail
setAuthDisabled(authDisabled)
if (authDisabled) {
// Authentication is disabled on server
console.log('Authentication is disabled on server, skipping login')
setIsAuthenticated(true)
setShowLogin(false)
setAuthCheckComplete(true)
} else {
// Authentication is enabled, check if we have credentials
setAuthCheckComplete(true)
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
if (username && password) {
// Credentials exist, connection will authenticate automatically
setIsConnecting(true)
} else {
// No credentials, show login dialog
setShowLogin(true)
}
}
}
// Listen for successful authentication from socket
const handleAuthSuccess = (event: CustomEvent) => {
console.log('Authentication successful')
@@ -68,9 +36,6 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const errorMessage = event.detail?.message || 'Authentication failed'
console.error('Authentication error:', errorMessage)
// Mark auth check as complete - we now know auth is required
setAuthCheckComplete(true)
// Clear authentication state
setIsAuthenticated(false)
setShowLogin(true)
@@ -101,15 +66,23 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
}
}
// Connect socket to trigger auth-status event
connectSocket()
window.addEventListener('mqtt-auth-status', handleAuthStatus as EventListener)
window.addEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
window.addEventListener('mqtt-auth-error', handleAuthError as EventListener)
// Check if already authenticated
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
if (username && password) {
// Credentials exist, try to connect with them
setIsConnecting(true)
connectSocket()
} else {
// No credentials, show login dialog
setShowLogin(true)
}
return () => {
window.removeEventListener('mqtt-auth-status', handleAuthStatus as EventListener)
window.removeEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
window.removeEventListener('mqtt-auth-error', handleAuthError as EventListener)
}
@@ -136,14 +109,9 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
return <>{props.children}</>
}
// Show nothing while checking auth status to avoid flash
if (!authCheckComplete) {
return null
}
if (!isAuthenticated) {
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} waitTimeSeconds={waitTimeSeconds} />
}
return <AuthContext.Provider value={{ authDisabled }}>{props.children}</AuthContext.Provider>
return <>{props.children}</>
}
@@ -1,112 +0,0 @@
import { expect } from 'chai'
import { renderWithProviders } from '../../utils/spec/testUtils'
import Chart from './Chart'
describe('Chart X-Axis Domain Investigation', () => {
it('should spread points across X-axis with sequential timestamps', () => {
// Create 5 data points, 1 second (1000ms) apart
const now = Date.now()
const data = [
{ x: now - 4000, y: 20 },
{ x: now - 3000, y: 21 },
{ x: now - 2000, y: 22 },
{ x: now - 1000, y: 23 },
{ x: now, y: 24 }
]
const { container } = renderWithProviders(<Chart data={data} />)
// Find all circle elements (data points)
const circles = container.querySelectorAll('svg circle')
expect(circles).to.have.length(5)
// Extract cx (X-position) values
const cxValues: number[] = []
circles.forEach(circle => {
const cx = circle.getAttribute('cx')
if (cx) {
cxValues.push(parseFloat(cx))
}
})
// Log for debugging
console.log('\n========== X-AXIS DOMAIN INVESTIGATION ==========')
console.log('Data X values (timestamps):')
data.forEach((d, i) => console.log(` Point ${i}: ${d.x} (${new Date(d.x).toISOString()})`))
console.log(`\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)`)
console.log('\nRendered circle CX positions:')
cxValues.forEach((cx, i) => console.log(` Circle ${i}: cx=${cx.toFixed(2)}px`))
const minCx = Math.min(...cxValues)
const maxCx = Math.max(...cxValues)
const cxRange = maxCx - minCx
console.log(`\nCX position range: ${cxRange.toFixed(2)}px (from ${minCx.toFixed(2)} to ${maxCx.toFixed(2)})`)
console.log(`Points per pixel: ${(cxValues.length / cxRange).toFixed(4)}`)
// Calculate spacing between consecutive points
const spacings: number[] = []
for (let i = 1; i < cxValues.length; i++) {
spacings.push(cxValues[i] - cxValues[i - 1])
}
console.log('\nSpacing between consecutive points:')
spacings.forEach((s, i) => console.log(` ${i} to ${i+1}: ${s.toFixed(2)}px`))
const avgSpacing = spacings.reduce((a, b) => a + b, 0) / spacings.length
console.log(`Average spacing: ${avgSpacing.toFixed(2)}px`)
console.log('=================================================\n')
// Assertions:
// 1. Points should be spread out (CX range should be significant, not bunched)
expect(cxRange).to.be.greaterThan(50, 'Points should be spread across at least 50px')
// 2. Points should be in ascending order (left to right)
for (let i = 1; i < cxValues.length; i++) {
expect(cxValues[i]).to.be.greaterThan(cxValues[i - 1],
`Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i-1} (cx=${cxValues[i-1]})`)
}
// 3. Spacing should be relatively uniform (since data points are equally spaced in time)
const spacingVariance = spacings.map(s => Math.abs(s - avgSpacing))
const maxVariance = Math.max(...spacingVariance)
expect(maxVariance).to.be.lessThan(avgSpacing * 0.5,
'Spacing between points should be relatively uniform')
})
it('should handle points bunched at far right correctly', () => {
// Simulate the "bunched up" scenario with very large timestamps
const largeTimestamp = 1703347200000 // Dec 23, 2023
const data = [
{ x: largeTimestamp, y: 20 },
{ x: largeTimestamp + 1000, y: 21 },
{ x: largeTimestamp + 2000, y: 22 },
{ x: largeTimestamp + 3000, y: 23 },
{ x: largeTimestamp + 4000, y: 24 }
]
const { container } = renderWithProviders(<Chart data={data} />)
const circles = container.querySelectorAll('svg circle')
const cxValues: number[] = []
circles.forEach(circle => {
const cx = circle.getAttribute('cx')
if (cx) {
cxValues.push(parseFloat(cx))
}
})
console.log('\n========== LARGE TIMESTAMP TEST ==========')
console.log('Data X values:', data.map(d => d.x))
console.log('Rendered CX values:', cxValues.map(v => v.toFixed(2)))
const minCx = Math.min(...cxValues)
const maxCx = Math.max(...cxValues)
console.log(`CX range: ${(maxCx - minCx).toFixed(2)}px`)
console.log('==========================================\n')
// Points should still be spread out even with large timestamps
expect(maxCx - minCx).to.be.greaterThan(50,
'Points with large timestamps should still be spread across the chart')
})
})
-514
View File
@@ -1,514 +0,0 @@
/**
* Chart Component Tests
*
* These tests verify the Chart component functionality including:
* - Rendering with various data configurations
* - Theme integration
* - Interactive features (tooltips, hover states)
* - Different curve interpolation types
* - Custom domains and ranges
* - Responsive behavior
*/
import React from 'react'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import Chart, { Props as ChartProps } from './Chart'
import { renderWithProviders, createMockChartData, screen } from '../../utils/spec/testUtils'
import { PlotCurveTypes } from '../../reducers/Charts'
describe('Chart Component', () => {
describe('Basic Rendering', () => {
it('should render without crashing with valid data', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container).to.exist
expect(container.querySelector('svg')).to.exist
})
it('should render NoData component when data is empty', () => {
const { container } = renderWithProviders(<Chart data={[]} />, { withTheme: true })
expect(container).to.exist
// NoData component should be rendered
const noDataElement = container.querySelector('div')
expect(noDataElement).to.exist
})
it('should render chart with correct height', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const chartContainer = container.querySelector('[style*="height"]') as HTMLElement
expect(chartContainer).to.exist
expect(chartContainer.style.height).to.equal('150px')
})
it('should render SVG chart elements', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Check for SVG element
const svg = container.querySelector('svg')
expect(svg).to.exist
// Check for chart elements (paths for line series)
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
})
})
describe('Data Visualization', () => {
it('should render data points as glyphs', () => {
const data = createMockChartData(3)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Check for circles (glyphs representing data points)
const circles = container.querySelectorAll('circle')
expect(circles.length).to.be.greaterThan(0)
})
it('should render exact number of data points matching data length', () => {
const dataLength = 5
const data = createMockChartData(dataLength)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Each data point should render as a circle
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(dataLength, `Expected ${dataLength} circles for ${dataLength} data points`)
// Verify each circle has proper attributes
circles.forEach((circle, index) => {
expect(circle.getAttribute('cx')).to.exist
expect(circle.getAttribute('cy')).to.exist
expect(circle.getAttribute('r')).to.equal('3')
expect(circle.getAttribute('fill')).to.exist
})
})
it('should position data points with valid coordinates', () => {
const data = createMockChartData(3)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const circles = container.querySelectorAll('circle')
circles.forEach((circle) => {
const cx = parseFloat(circle.getAttribute('cx') || '0')
const cy = parseFloat(circle.getAttribute('cy') || '0')
// Coordinates should be valid numbers
expect(cx).to.be.a('number')
expect(cy).to.be.a('number')
expect(isNaN(cx)).to.be.false
expect(isNaN(cy)).to.be.false
// Coordinates should be within chart bounds (positive values)
expect(cx).to.be.greaterThan(0)
expect(cy).to.be.greaterThan(0)
})
})
it('should render line connecting data points', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Line series should create a path element
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
})
it('should handle single data point', () => {
const data = [{ x: Date.now(), y: 50 }]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(1, 'Single data point should render as one circle')
})
it('should handle large datasets', () => {
const data = createMockChartData(100)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(100, '100 data points should render as 100 circles')
})
})
describe('Curve Interpolation', () => {
const curveTypes: PlotCurveTypes[] = ['curve', 'linear', 'cubic_basis_spline', 'step_after', 'step_before']
curveTypes.forEach((interpolation) => {
it(`should render with ${interpolation} interpolation`, () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(
<Chart data={data} interpolation={interpolation} />,
{ withTheme: true }
)
expect(container.querySelector('svg')).to.exist
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
})
})
})
describe('Custom Styling', () => {
it('should apply custom color', () => {
const data = createMockChartData(5)
const customColor = '#ff0000'
const { container } = renderWithProviders(
<Chart data={data} color={customColor} />,
{ withTheme: true }
)
// Check if custom color is applied to line or glyphs
const svg = container.querySelector('svg')
expect(svg).to.exist
})
it('should use theme colors when no custom color provided', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
describe('Custom Domains and Ranges', () => {
it('should render with custom Y range', () => {
const data = createMockChartData(5)
const range: [number, number] = [0, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
expect(container.querySelector('svg')).to.exist
})
it('should render with custom time range', () => {
const data = createMockChartData(5)
const timeRangeStart = 60000 // 1 minute
const { container } = renderWithProviders(
<Chart data={data} timeRangeStart={timeRangeStart} />,
{ withTheme: true }
)
expect(container.querySelector('svg')).to.exist
})
it('should render with partial Y range (only min)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [0, undefined]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
expect(container.querySelector('svg')).to.exist
})
it('should render with partial Y range (only max)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [undefined, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
expect(container.querySelector('svg')).to.exist
})
})
describe('Chart Components', () => {
it('should render Y-axis', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Y-axis should be present (look for axis group or tick marks)
const svg = container.querySelector('svg')
expect(svg).to.exist
// Axis typically contains text elements for labels
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(0)
})
it('should render X-axis with time labels', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// X-axis should be present with text labels
const svg = container.querySelector('svg')
expect(svg).to.exist
// X-axis has text labels for timestamps
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(0, 'X-axis and Y-axis should have text labels')
// At least one text element should contain time format (e.g., contains ":")
let hasTimeFormat = false
texts.forEach((text) => {
if (text.textContent && text.textContent.includes(':')) {
hasTimeFormat = true
}
})
expect(hasTimeFormat).to.be.true
})
it('should render both X and Y axes', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const svg = container.querySelector('svg')
expect(svg).to.exist
// Both axes should render tick marks (lines)
const lines = container.querySelectorAll('line')
expect(lines.length).to.be.greaterThan(0, 'Axes should render tick marks')
// Both axes should have labels (text)
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(2, 'Both axes should have multiple labels')
})
it('should render grid lines', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Grid lines are rendered as line elements
const svg = container.querySelector('svg')
expect(svg).to.exist
})
it('should have proper chart margins', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const svg = container.querySelector('svg')
expect(svg).to.exist
// SVG should have proper dimensions
expect(svg?.getAttribute('width')).to.exist
expect(svg?.getAttribute('height')).to.exist
})
})
describe('Edge Cases', () => {
it('should handle negative values', () => {
const data = [
{ x: Date.now() - 2000, y: -50 },
{ x: Date.now() - 1000, y: -25 },
{ x: Date.now(), y: -75 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
it('should handle zero values', () => {
const data = [
{ x: Date.now() - 2000, y: 0 },
{ x: Date.now() - 1000, y: 0 },
{ x: Date.now(), y: 0 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
it('should handle very large numbers', () => {
const data = [
{ x: Date.now() - 2000, y: 1000000 },
{ x: Date.now() - 1000, y: 2000000 },
{ x: Date.now(), y: 3000000 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
// Y-axis should abbreviate large numbers
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(0)
})
it('should handle identical values', () => {
const data = [
{ x: Date.now() - 2000, y: 50 },
{ x: Date.now() - 1000, y: 50 },
{ x: Date.now(), y: 50 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
describe('Component Props', () => {
it('should accept all valid props without errors', () => {
const data = createMockChartData(5)
const props: ChartProps = {
data,
interpolation: 'curve',
range: [0, 100],
timeRangeStart: 60000,
color: '#00ff00',
}
const { container } = renderWithProviders(<Chart {...props} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
it('should work with minimal props', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
describe('Theme Integration', () => {
it('should render in light theme', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
// Note: Testing dark theme would require a custom theme provider
// This demonstrates how the test structure supports theme variations
})
describe('Performance', () => {
it('should memoize component with same props', () => {
const data = createMockChartData(5)
const { rerender } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Component should not re-render with same props due to React.memo
expect(() => {
rerender(<Chart data={data} />)
}).to.not.throw()
})
it('should handle rapid data updates', () => {
const { rerender, container } = renderWithProviders(
<Chart data={createMockChartData(5)} />,
{ withTheme: true }
)
// Simulate rapid updates
for (let i = 0; i < 10; i++) {
rerender(<Chart data={createMockChartData(5)} />)
}
expect(container.querySelector('svg')).to.exist
})
})
describe('Interactive Data Updates', () => {
it('should dynamically update when data points are added', () => {
// Start with 3 data points
const initialData = createMockChartData(3)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
// Verify initial state: should have 3 data points
const initialCircles = container.querySelectorAll('circle')
expect(initialCircles.length).to.equal(3, 'Should initially render 3 data points')
// Verify each initial circle has valid attributes
initialCircles.forEach((circle, index) => {
const cx = circle.getAttribute('cx')
const cy = circle.getAttribute('cy')
const r = circle.getAttribute('r')
expect(cx).to.exist
expect(cy).to.exist
expect(r).to.equal('3')
expect(parseFloat(cx!)).to.be.a('number').and.not.NaN
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
})
// Update state: add 2 more data points (total 5)
const updatedData = createMockChartData(5)
rerender(<Chart data={updatedData} />)
// Verify updated state: should now have 5 data points
const updatedCircles = container.querySelectorAll('circle')
expect(updatedCircles.length).to.equal(5, 'Should render 5 data points after update')
// Verify each updated circle has valid attributes
updatedCircles.forEach((circle, index) => {
const cx = circle.getAttribute('cx')
const cy = circle.getAttribute('cy')
const r = circle.getAttribute('r')
const fill = circle.getAttribute('fill')
expect(cx).to.exist
expect(cy).to.exist
expect(r).to.equal('3')
expect(fill).to.exist
expect(parseFloat(cx!)).to.be.a('number').and.not.NaN
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
expect(parseFloat(cy!)).to.be.greaterThan(0, 'Y coordinate should be positive')
})
// Verify the line path is updated to connect all 5 points
const linePath = container.querySelector('path[stroke]')
expect(linePath).to.exist
expect(linePath!.getAttribute('d')).to.exist
// The path should start with MoveTo (M) command and contain curve/line commands
const pathData = linePath!.getAttribute('d')
expect(pathData).to.include('M') // MoveTo command for first point
// Path may contain 'L' (line) or 'C' (curve) commands depending on interpolation
expect(pathData!.length).to.be.greaterThan(10, 'Path should have substantial data for 5 points')
})
it('should handle data point removal', () => {
// Start with 5 data points
const initialData = createMockChartData(5)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
// Verify initial state
let circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(5, 'Should initially render 5 data points')
// Remove 2 data points (now 3)
const reducedData = createMockChartData(3)
rerender(<Chart data={reducedData} />)
// Verify reduced state
circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(3, 'Should render 3 data points after removal')
})
it('should maintain chart structure during data updates', () => {
const initialData = createMockChartData(3)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
// Verify chart structure exists initially
expect(container.querySelector('svg')).to.exist
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should have axis/grid lines')
expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should have axis labels')
// Update data
const updatedData = createMockChartData(5)
rerender(<Chart data={updatedData} />)
// Verify chart structure is maintained after update
expect(container.querySelector('svg')).to.exist
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should still have axis/grid lines')
expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should still have axis labels')
})
})
})
+35 -101
View File
@@ -1,7 +1,7 @@
import DateFormatter from '../helper/DateFormatter'
import NoData from './NoData'
import NumberFormatter from '../helper/NumberFormatter'
import React, { memo, useCallback, useMemo } from 'react'
import React, { memo, useCallback, useRef, useEffect } from 'react'
import TooltipComponent from './TooltipComponent'
import { useResizeDetector } from 'react-resize-detector'
import { emphasize, useTheme } from '@mui/material/styles'
@@ -10,7 +10,8 @@ import { PlotCurveTypes } from '../../reducers/Charts'
import { Point, Tooltip } from './Model'
import { useCustomXDomain } from './effects/useCustomXDomain'
import { useCustomYDomain } from './effects/useCustomYDomain'
import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart'
import 'react-vis/dist/style.css'
const { XYPlot, LineMarkSeries, YAxis, HorizontalGridLines, Hint } = require('react-vis')
const abbreviate = require('number-abbreviate')
export interface Props {
@@ -21,14 +22,10 @@ export interface Props {
color?: string
}
const CHART_HEIGHT = 150
export default memo((props: Props) => {
const theme = useTheme()
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
const [hoveredPoint, setHoveredPoint] = React.useState<Point | undefined>()
const { width = 300, ref } = useResizeDetector()
const chartContainerRef = React.useRef<HTMLDivElement>(null)
const hintFormatter = React.useCallback(
(point: any) => [
@@ -41,19 +38,14 @@ export default memo((props: Props) => {
const onMouseLeave = React.useCallback(() => {
setTooltip(undefined)
setHoveredPoint(undefined)
}, [])
const showTooltip = React.useCallback(
(point: Point) => {
if (!chartContainerRef.current) {
return
}
setHoveredPoint(point)
setTooltip({ point, value: hintFormatter(point), element: chartContainerRef.current })
},
[hintFormatter]
)
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 =
theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light
@@ -61,105 +53,47 @@ export default memo((props: Props) => {
const highlightSelectedPoint = useCallback(
(point: Point) => {
const highlight = hoveredPoint && hoveredPoint.x === point.x && hoveredPoint.y === point.y
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
return highlight ? emphasize(color, 0.8) : color
},
[hoveredPoint, color]
[tooltip, color]
)
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
const formatXAxis = useCallback((timestamp: number) => {
const date = new Date(timestamp)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}, [])
const xDomain = useCustomXDomain(props)
const yDomain = useCustomYDomain(props)
const data = props.data
const hasData = data.length > 0
const dummyDomain: [number, number] = [-1, 1]
const dummyDomain = [-1, 1]
const dummyData = [{ x: -2, y: -2 }]
const accessors = useMemo(
() => ({
xAccessor: (d: Point) => d.x,
yAccessor: (d: Point) => d.y,
}),
[]
)
return (
<div>
<div ref={ref} style={{ height: `${CHART_HEIGHT}px`, width: '100%', position: 'relative' }}>
<div ref={ref} style={{ height: '150px', width: '100%', position: 'relative' }}>
{data.length === 0 ? <NoData /> : null}
<div ref={chartContainerRef}>
<XYChart
width={width || 300}
height={CHART_HEIGHT}
margin={{ top: 10, right: 10, bottom: 30, left: 50 }}
xScale={{ type: 'time', domain: xDomain || dummyDomain }}
yScale={{ type: 'linear', domain: hasData ? yDomain : dummyDomain }}
onPointerOut={onMouseLeave}
>
<Grid rows={true} columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
<Axis
orientation="left"
numTicks={5}
tickFormat={formatYAxis}
stroke={theme.palette.text.secondary}
tickStroke={theme.palette.text.secondary}
tickLabelProps={() => ({ fontSize: 11, fill: theme.palette.text.secondary })}
/>
<Axis
orientation="bottom"
numTicks={4}
tickFormat={formatXAxis}
stroke={theme.palette.text.secondary}
tickStroke={theme.palette.text.secondary}
tickLabelProps={() => ({ fontSize: 10, fill: theme.palette.text.secondary, textAnchor: 'middle' })}
/>
<LineSeries
dataKey="line"
data={hasData ? data : dummyData}
xAccessor={accessors.xAccessor}
yAccessor={accessors.yAccessor}
stroke={color}
strokeWidth={2}
curve={mapCurveType(props.interpolation)}
onPointerMove={(datum) => {
if (datum && datum.datum) {
const point = datum.datum as Point
showTooltip(point)
}
}}
/>
<GlyphSeries
dataKey="points"
data={hasData ? data : dummyData}
xAccessor={accessors.xAccessor}
yAccessor={accessors.yAccessor}
renderGlyph={(glyphProps) => {
const point = glyphProps.datum as Point
const pointColor = highlightSelectedPoint(point)
return (
<circle
cx={glyphProps.x}
cy={glyphProps.y}
r={3}
fill={pointColor}
/>
)
}}
/>
</XYChart>
</div>
{/* Custom tooltip outside of visx to maintain exact same appearance */}
<TooltipComponent tooltip={tooltip} />
<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>
)
@@ -3,22 +3,8 @@ import { Props } from '../Chart'
export function useCustomXDomain(props: Props): [number, number] | undefined {
return useMemo(() => {
if (props.data.length === 0) {
return undefined
}
const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0]
const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now()
if (props.timeRangeStart) {
// Custom time range mode
return [Date.now() - props.timeRangeStart, lastDataDate]
} else {
// Auto-calculate from data (like react-vis did)
const xValues = props.data.map(d => d.x)
const minX = Math.min(...xValues)
const maxX = Math.max(...xValues)
return [minX, maxX]
}
return props.timeRangeStart ? [Date.now() - props.timeRangeStart, lastDataDate] : undefined
}, [props.data, props.timeRangeStart])
}
+6 -8
View File
@@ -1,19 +1,17 @@
import { PlotCurveTypes } from '../../reducers/Charts'
import * as d3Shape from 'd3-shape'
export function mapCurveType(type: PlotCurveTypes | undefined) {
switch (type) {
case 'curve':
return d3Shape.curveMonotoneX
return 'curveMonotoneX'
case 'linear':
return d3Shape.curveLinear
return 'curveLinear'
case 'cubic_basis_spline':
return d3Shape.curveBasis
return 'curveBasis'
case 'step_after':
return d3Shape.curveStepAfter
return 'curveStepAfter'
case 'step_before':
return d3Shape.curveStepBefore
return 'curveStepBefore'
default:
return d3Shape.curveMonotoneX
return 'curveMonotoneX'
}
}
+12 -42
View File
@@ -46,15 +46,10 @@ function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpaci
}
}
// Helper function to generate unique keys for charts
const getChartKey = (chart: ChartParameters) => `${chart.topic}-${chart.dotPath || ''}`
function ChartPanel(props: Props) {
const chartsInView = props.charts.count()
const [spacing, setSpacing] = React.useState(spacingForChartCount(chartsInView))
const nodeRefsMap = React.useRef<Map<string, React.RefObject<HTMLDivElement>>>(new Map())
React.useEffect(() => {
props.actions.chart.loadCharts()
@@ -70,42 +65,17 @@ function ChartPanel(props: Props) {
}
}, [chartsInView])
// Clean up refs for removed charts
React.useEffect(() => {
const currentKeys = new Set(props.charts.map(getChartKey).toArray())
const refsToDelete: string[] = []
nodeRefsMap.current.forEach((_, key) => {
if (!currentKeys.has(key)) {
refsToDelete.push(key)
}
})
refsToDelete.forEach(key => nodeRefsMap.current.delete(key))
}, [props.charts])
const charts = props.charts.map(chartParameters => {
const key = getChartKey(chartParameters)
// Get or create a ref for this specific chart
if (!nodeRefsMap.current.has(key)) {
nodeRefsMap.current.set(key, React.createRef<HTMLDivElement>())
}
const nodeRef = nodeRefsMap.current.get(key)!
return (
<CSSTransition
key={key}
timeout={{ enter: 500, exit: 500 }}
classNames="example"
nodeRef={nodeRef}
>
<Grid item xs={mapWidth(chartParameters.width, spacing)} ref={nodeRef}>
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
</Grid>
</CSSTransition>
)
})
const charts = props.charts.map(chartParameters => (
<CSSTransition
key={`${chartParameters.topic}-${chartParameters.dotPath || ''}`}
timeout={{ enter: 500, exit: 500 }}
classNames="example"
>
<Grid item xs={mapWidth(chartParameters.width, spacing)}>
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
</Grid>
</CSSTransition>
))
return (
<div className={props.classes.container}>
@@ -154,7 +124,7 @@ const styles = (theme: Theme) => ({
height: '100%',
padding: '8px',
flex: 1,
overflow: 'auto',
overflow: 'hidden scroll',
},
})
@@ -8,7 +8,7 @@ import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@mui/styles'
import { rendererRpc } from '../../eventBus'
import { rendererRpc } from '../../../../events'
import { RpcEvents } from '../../../../events/EventsV2'
function BrowserCertificateFileSelection(props: {
@@ -8,29 +8,15 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
if (connecting) {
return (
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
data-testid="abort-button"
aria-label="Cancel connection attempt"
>
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="abort-button">
<ConnectionHealthIndicator />
&nbsp;&nbsp;Cancel
&nbsp;&nbsp;Abort
</Button>
)
}
return (
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
data-testid="connect-button"
aria-label="Connect to MQTT broker"
>
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="connect-button">
<PowerSettingsNew /> Connect
</Button>
)
@@ -8,7 +8,7 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, connectionManagerActions, globalActions } from '../../actions'
import { connectionActions, connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { KeyCodes } from '../../utils/KeyCodes'
import { Theme } from '@mui/material/styles'
@@ -17,12 +17,14 @@ import { ToggleSwitch } from './ToggleSwitch'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import {
Button,
FormControl,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
MenuItem,
TextField,
Tooltip,
} from '@mui/material'
interface Props {
@@ -30,7 +32,6 @@ interface Props {
classes: { [s: string]: string }
actions: typeof connectionActions
managerActions: typeof connectionManagerActions
globalActions: typeof globalActions
connected: boolean
connecting: boolean
}
@@ -40,17 +41,6 @@ const protocols = ['mqtt', 'ws']
function ConnectionSettings(props: Props) {
const [showPassword, setShowPassword] = useState(false)
const handleDelete = useCallback(async () => {
const confirmed = await props.globalActions.requestConfirmation(
'Delete Connection',
`Are you sure you want to delete the connection "${props.connection.name}"?\n\nThis action cannot be undone.`
)
if (confirmed) {
props.managerActions.deleteConnection(props.connection.id)
}
}, [props.connection.id, props.connection.name, props.globalActions, props.managerActions])
const toggleConnect = useCallback(() => {
if (props.connecting) {
props.actions.disconnect()
@@ -86,7 +76,7 @@ function ConnectionSettings(props: Props) {
className={props.classes.textField}
value={props.connection.basePath}
onChange={handleChange('basePath')}
margin="dense"
margin="normal"
/>
</Grid>
)
@@ -111,26 +101,21 @@ function ConnectionSettings(props: Props) {
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
{value}://
</MenuItem>
))
return (
<Tooltip title="Use 'mqtt' for standard connections or 'ws' for WebSocket connections" arrow>
<TextField
select={true}
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={updateProtocol}
margin="dense"
inputProps={{
'aria-label': 'MQTT protocol'
}}
>
{protocolItems}
</TextField>
</Tooltip>
<TextField
select={true}
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={updateProtocol}
margin="normal"
>
{protocolItems}
</TextField>
)
}
@@ -159,15 +144,9 @@ function ConnectionSettings(props: Props) {
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
return (
<InputAdornment position="end">
<Tooltip title={props.showPassword ? "Hide password" : "Show password"} arrow>
<IconButton
aria-label={props.showPassword ? "Hide password" : "Show password"}
onClick={props.toggle}
edge="end"
>
{props.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</Tooltip>
<IconButton aria-label="Toggle password visibility" onClick={props.toggle}>
{props.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}
@@ -175,9 +154,9 @@ function ConnectionSettings(props: Props) {
const { classes, connection } = props
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<form className={classes.container} noValidate={true} autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
<Grid container={true} spacing={2}>
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={5}>
<TextField
autoFocus={true}
@@ -185,11 +164,7 @@ function ConnectionSettings(props: Props) {
className={classes.textField}
value={connection.name}
onChange={handleChange('name')}
margin="dense"
placeholder="My MQTT Connection"
inputProps={{
'aria-label': 'Connection name'
}}
margin="normal"
/>
</Grid>
<Grid item={true} xs={4}>
@@ -212,12 +187,7 @@ function ConnectionSettings(props: Props) {
className={classes.textField}
value={connection.host}
onChange={handleChange('host')}
margin="dense"
placeholder="broker.example.com"
inputProps={{
'data-testid': 'host-input',
'aria-label': 'MQTT broker host'
}}
margin="normal"
/>
</Grid>
<Grid item={true} xs={3}>
@@ -226,14 +196,7 @@ function ConnectionSettings(props: Props) {
className={classes.textField}
value={connection.port}
onChange={handleChange('port')}
margin="dense"
type="number"
placeholder="1883"
inputProps={{
'aria-label': 'MQTT broker port',
min: 1,
max: 65535
}}
margin="normal"
/>
</Grid>
{requiresBasePath() ? renderBasePathInput() : null}
@@ -243,74 +206,54 @@ function ConnectionSettings(props: Props) {
className={classes.textField}
value={connection.username}
onChange={handleChange('username')}
margin="dense"
placeholder="Optional"
inputProps={{
'aria-label': 'MQTT username',
'autoComplete': 'username'
}}
margin="normal"
/>
</Grid>
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Password"
className={classes.textField}
type={showPassword ? 'text' : 'password'}
value={connection.password}
onChange={handleChange('password')}
margin="dense"
placeholder="Optional"
InputProps={{
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />
}}
inputProps={{
'aria-label': 'MQTT password',
'autoComplete': 'current-password'
}}
/>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="adornment-password">Password</InputLabel>
<Input
id="adornment-password"
type={showPassword ? 'text' : 'password'}
value={connection.password}
onChange={handleChange('password')}
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
/>
</FormControl>
</Grid>
</Grid>
</form>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(0, 0, 0, 0.12)' }}>
<br />
<div>
<Tooltip title="Delete this connection permanently" arrow>
<div style={{ float: 'left' }}>
<Button
variant="contained"
color="error"
className={classes.button}
onClick={handleDelete}
aria-label="Delete connection"
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
>
<Delete /> Delete
Delete <Delete />
</Button>
</Tooltip>
<Tooltip title="Advanced connection settings" arrow>
<Button
variant="contained"
className={classes.button}
onClick={props.managerActions.toggleAdvancedSettings}
data-testid="advanced-button"
aria-label="Show advanced settings"
>
<Settings /> Advanced
</Button>
</Tooltip>
</div>
<div>
<Tooltip title="Save connection settings" arrow>
</div>
<div style={{ float: 'right' }}>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={props.managerActions.saveConnectionSettings}
aria-label="Save connection"
>
<Save /> Save
</Button>
</Tooltip>
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
</div>
</div>
</div>
</form>
</div>
)
}
@@ -326,7 +269,6 @@ const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
@@ -2,7 +2,6 @@ import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
const ConnectionSettingsAny = ConnectionSettings as any
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
@@ -67,15 +66,10 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
</div>
<div className={classes.right} key={connection && connection.id}>
<Toolbar>
<div className={classes.toolbarContent}>
<div className={classes.desktopTitle}>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
</div>
<MobileConnectionSelector />
</div>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
</Toolbar>
{this.renderSettings()}
</div>
@@ -92,20 +86,6 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
},
toolbarContent: {
width: '100%',
display: 'flex',
alignItems: 'center',
},
desktopTitle: {
display: 'flex',
alignItems: 'center',
flex: 1,
// Hide on mobile - connection selector will take its place
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
root: {
margin: `calc((100vh - ${connectionHeight}) / 2) auto 0 auto`,
minWidth: '800px',
@@ -113,14 +93,6 @@ const styles = (theme: Theme) => ({
height: connectionHeight,
outline: 'none' as 'none',
display: 'flex' as 'flex',
// Mobile responsive adjustments
[theme.breakpoints.down('md')]: {
minWidth: '95vw',
maxWidth: '95vw',
height: '85vh',
margin: '7.5vh auto 0 auto',
flexDirection: 'column' as 'column',
},
},
left: {
borderRightStyle: 'dotted' as 'dotted',
@@ -131,21 +103,12 @@ const styles = (theme: Theme) => ({
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
// Mobile: hide profile list to save space
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
right: {
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
flex: 10,
// Mobile: enable scrolling
[theme.breakpoints.down('md')]: {
borderRadius: `${theme.shape.borderRadius}px`,
overflowY: 'auto' as 'auto',
},
},
connectionUri: {
width: '27em',
@@ -1,140 +0,0 @@
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const styles = (theme: Theme) => ({
container: {
display: 'none',
// Only show on mobile, takes full width
[theme.breakpoints.down('md')]: {
display: 'flex',
flex: 1,
alignItems: 'center',
gap: theme.spacing(1),
},
},
select: {
flex: 1,
fontSize: '1rem',
'& .MuiSelect-select': {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
},
},
addButton: {
padding: theme.spacing(1),
},
})
interface Props {
classes: any
connections: Array<{ id: string; name?: string; host?: string }>
currentConnectionId?: string
isConnected: boolean
currentActiveConnectionId?: string
actions: typeof connectionManagerActions
}
class MobileConnectionSelector extends React.PureComponent<Props, {}> {
private handleConnectionChange = (event: SelectChangeEvent<string>) => {
const connectionId = event.target.value
this.props.actions.selectConnection(connectionId)
}
private handleCreateConnection = () => {
this.props.actions.createConnection()
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
return connection.name || connection.host || 'Unnamed Connection'
}
public render() {
const { classes, connections, currentConnectionId, isConnected, currentActiveConnectionId } = this.props
if (!connections || connections.length === 0) {
return null
}
return (
<div className={classes.container}>
<Select
className={classes.select}
value={currentConnectionId || ''}
onChange={this.handleConnectionChange}
aria-label="Select MQTT connection"
displayEmpty
MenuProps={{
PaperProps: {
style: {
maxHeight: '60vh',
},
},
}}
>
{connections.map(conn => {
// Show "(Connected)" only when:
// 1. This connection is selected in the UI (currentConnectionId)
// 2. There's an active MQTT connection (isConnected)
// 3. The active connection matches this connection (currentActiveConnectionId)
// This prevents showing "Connected" when a connection is selected but not connected,
// or when switching between connections during a disconnect/reconnect cycle.
const showConnectedStatus =
conn.id === currentConnectionId && isConnected && conn.id === currentActiveConnectionId
const displayName = this.getConnectionDisplayName(conn)
return (
<MenuItem key={conn.id} value={conn.id}>
{displayName}
{showConnectedStatus && ' (Connected)'}
</MenuItem>
)
})}
</Select>
<IconButton
className={classes.addButton}
onClick={this.handleCreateConnection}
aria-label="Create new connection"
size="medium"
>
<Add />
</IconButton>
</div>
)
}
}
const mapStateToProps = (state: AppState) => {
const connectionManager = state.connectionManager
const connections =
connectionManager && connectionManager.connections
? Object.values(connectionManager.connections).map(conn => ({
id: conn.id,
name: conn.name,
host: conn.host,
}))
: []
return {
connections,
currentConnectionId: state.connectionManager?.selected,
isConnected: state.connection.connected,
currentActiveConnectionId: state.connection.connectionId,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
// Using 'as any' here is consistent with other Material-UI + Redux connected components
// in this codebase (see ConnectionSettings.tsx, ProfileList/index.tsx, ChartPanel/index.tsx)
// to work around complex TypeScript type inference issues with the withStyles + connect HOCs
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(MobileConnectionSelector) as any)
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { ListItem, Typography } from '@mui/material'
import { ListItem, Typography, Box } from '@mui/material'
import { DragIndicator } from '@mui/icons-material'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
@@ -15,6 +16,9 @@ export interface Props {
}
selected: boolean
classes: any
onDragStart: (connectionId: string) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (connectionId: string) => void
}
const ConnectionItem = (props: Props) => {
@@ -25,20 +29,48 @@ const ConnectionItem = (props: Props) => {
}
}, [props.connection, props])
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation()
props.onDragStart(props.connection.id)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
props.onDragOver(e)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
props.onDrop(props.connection.id)
}
const connection = props.connection.host && toMqttConnection(props.connection)
return (
<ListItem
button={true}
selected={props.selected}
style={{ display: 'block' }}
className={props.classes.itemContainer}
onClick={() => props.actions.connectionManager.selectConnection(props.connection.id)}
onDoubleClick={() => {
props.actions.connectionManager.selectConnection(props.connection.id)
connect()
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
<Box
className={props.classes.dragHandle}
draggable
onDragStart={handleDragStart}
>
<DragIndicator fontSize="small" />
</Box>
<Box className={props.classes.textContainer}>
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
</Box>
</ListItem>
)
}
@@ -66,6 +98,25 @@ export const connectionItemStyle = (theme: Theme) => ({
color: theme.palette.text.secondary,
fontSize: '0.7em',
},
itemContainer: {
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
padding: '8px 8px 8px 8px',
},
textContainer: {
flex: 1,
overflow: 'hidden' as 'hidden',
},
dragHandle: {
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
marginRight: '8px',
cursor: 'grab' as 'grab',
color: theme.palette.text.secondary,
'&:active': {
cursor: 'grabbing' as 'grabbing',
},
},
})
export default connect(null, mapDispatchToProps)(withStyles(connectionItemStyle)(ConnectionItem) as any)
@@ -1,6 +1,6 @@
import ConnectionItem from './ConnectionItem'
const ConnectionItemAny = ConnectionItem as any
import React from 'react'
import React, { useState } from 'react'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
@@ -22,13 +22,14 @@ interface Props {
function ProfileList(props: Props) {
const { actions, classes, connections, selected } = props
const [draggedConnectionId, setDraggedConnectionId] = useState<string | null>(null)
const selectConnection = (dir: 'next' | 'previous') => (event: KeyboardEvent) => {
if (!selected) {
return
}
const indexDirection = dir === 'next' ? 1 : -1
const connectionArray = Object.values(connections)
const connectionArray = Object.values(connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const selectedIndex = connectionArray.map(connection => connection.id).indexOf(selected)
const nextConnection = connectionArray[selectedIndex + indexDirection]
if (nextConnection) {
@@ -40,6 +41,39 @@ function ProfileList(props: Props) {
useGlobalKeyEventHandler(KeyCodes.arrow_down, selectConnection('next'))
useGlobalKeyEventHandler(KeyCodes.arrow_up, selectConnection('previous'))
const handleDragStart = (connectionId: string) => {
setDraggedConnectionId(connectionId)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
const handleDrop = (targetConnectionId: string) => {
if (!draggedConnectionId || draggedConnectionId === targetConnectionId) {
setDraggedConnectionId(null)
return
}
const sortedConnections = Object.values(connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const draggedIndex = sortedConnections.findIndex(c => c.id === draggedConnectionId)
const targetIndex = sortedConnections.findIndex(c => c.id === targetConnectionId)
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedConnectionId(null)
return
}
// Swap order values
const draggedConnection = sortedConnections[draggedIndex]
const targetConnection = sortedConnections[targetIndex]
actions.updateConnection(draggedConnection.id, { order: targetConnection.order })
actions.updateConnection(targetConnection.id, { order: draggedConnection.order })
setDraggedConnectionId(null)
}
const createConnectionButton = (
<div style={{ padding: '8px 16px' }}>
<AddButton action={actions.createConnection} />
@@ -50,9 +84,18 @@ function ProfileList(props: Props) {
return (
<List style={{ height: '100%' }} component="nav" subheader={createConnectionButton}>
<div className={classes.list}>
{Object.values(connections).map(connection => (
<ConnectionItemAny connection={connection} key={connection.id} selected={selected === connection.id} />
))}
{Object.values(connections)
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map(connection => (
<ConnectionItemAny
connection={connection}
key={connection.id}
selected={selected === connection.id}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
))}
</div>
</List>
)
@@ -3,25 +3,10 @@ import { FormControlLabel, Switch } from '@mui/material'
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
const { classes, value, toggle, label } = props
const toggleSwitch = (
<Switch
checked={value}
onChange={toggle}
color="primary"
role="switch"
aria-checked={value}
inputProps={{
'aria-label': label
}}
/>
)
const toggleSwitch = <Switch checked={value} onChange={toggle} color="primary" />
return (
<div className={classes.switch}>
<FormControlLabel
control={toggleSwitch}
label={`${label} (${value ? 'On' : 'Off'})`}
labelPlacement="bottom"
/>
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
</div>
)
}
+2 -106
View File
@@ -8,10 +8,6 @@ import { connect } from 'react-redux'
import { List } from 'immutable'
import { Sidebar } from '../Sidebar'
import { useResizeDetector } from 'react-resize-detector'
import MobileTabs from './MobileTabs'
import PublishTab from '../Sidebar/PublishTab'
import { setMobileTab } from '../../actions/Global'
import { Dispatch } from 'redux'
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
const ReactSplitPane = ReactSplitPaneImport as any
@@ -21,32 +17,14 @@ interface Props {
paneDefaults: any
connectionId?: string
chartPanelItems: List<ChartParameters>
mobileTab: number
dispatch: Dispatch<any>
}
function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width)
// Use state for mobile detection that updates on resize
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
const [height, setHeight] = React.useState<string | number>('100%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
const [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
// Update mobile state on resize
React.useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
// Set initial state
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
@@ -90,82 +68,6 @@ function ContentView(props: Props) {
}
}, [props.chartPanelItems])
// Mobile view with tab switcher
if (isMobile) {
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 64px)', // Full viewport minus titlebar
width: '100%',
}
const tabContentStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0, // Critical for flex children with overflow
width: '100%',
overflow: 'hidden',
position: 'relative',
}
// Tree container needs explicit height for the Tree component's height: 100% to work
const treeContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
}
const sidebarContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
overflow: 'auto',
}
return (
<div style={mobileContainerStyle}>
<MobileTabs value={props.mobileTab} onChange={(tab) => props.dispatch(setMobileTab(tab))} />
<div style={tabContentStyle}>
{/* Topics tab */}
{props.mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{props.mobileTab === 1 && (
<div style={sidebarContainerStyle}>
<Sidebar connectionId={props.connectionId} />
</div>
)}
{/* Publish tab */}
{props.mobileTab === 2 && (
<div style={sidebarContainerStyle}>
<PublishTab connectionId={props.connectionId} />
</div>
)}
{/* Charts tab */}
{props.mobileTab === 3 && (
<div style={sidebarContainerStyle}>
<ChartPanel />
</div>
)}
</div>
</div>
)
}
// Desktop view with split panes
return (
<div className={props.paneDefaults}>
<span>
@@ -207,12 +109,7 @@ function ContentView(props: Props) {
<div ref={widthRef} style={{ height: '100%' }}>
<div
className={props.paneDefaults}
style={{
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'
}}
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
>
<Sidebar connectionId={props.connectionId} />
</div>
@@ -226,7 +123,6 @@ function ContentView(props: Props) {
const mapStateToProps = (state: AppState) => {
return {
chartPanelItems: state.charts.get('charts'),
mobileTab: state.globalState.get('mobileTab'),
}
}
-91
View File
@@ -1,91 +0,0 @@
import * as React from 'react'
import { Tabs, Tab, Box } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import AccountTreeIcon from '@mui/icons-material/AccountTree'
import InfoIcon from '@mui/icons-material/Info'
import SendIcon from '@mui/icons-material/Send'
import ShowChartIcon from '@mui/icons-material/ShowChart'
interface Props {
classes: any
value: number
onChange: (value: number) => void
}
function MobileTabs(props: Props) {
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
props.onChange(newValue)
}
return (
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
<Tabs
value={props.value}
onChange={handleChange}
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
aria-label="Topics, Details, Publish and Charts tabs"
>
<Tab
icon={<AccountTreeIcon />}
label="Topics"
data-testid="mobile-tab-topics"
aria-label="View topics tree"
id="mobile-tab-0"
aria-controls="mobile-tabpanel-0"
/>
<Tab
icon={<InfoIcon />}
label="Details"
data-testid="mobile-tab-details"
aria-label="View topic details"
id="mobile-tab-1"
aria-controls="mobile-tabpanel-1"
/>
<Tab
icon={<SendIcon />}
label="Publish"
data-testid="mobile-tab-publish"
aria-label="Publish messages"
id="mobile-tab-2"
aria-controls="mobile-tabpanel-2"
/>
<Tab
icon={<ShowChartIcon />}
label="Charts"
data-testid="mobile-tab-charts"
aria-label="View charts"
id="mobile-tab-3"
aria-controls="mobile-tabpanel-3"
/>
</Tabs>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
position: 'relative' as 'relative',
zIndex: 1,
minHeight: '56px', // Touch-friendly tab height
'& .MuiTab-root': {
minHeight: '56px', // 48px minimum + padding
fontSize: '16px', // Prevent iOS zoom
fontWeight: 500,
padding: theme.spacing(1.5, 2),
textTransform: 'none' as 'none', // Better readability
'&:active': {
opacity: 0.7, // Touch feedback
},
},
'& .MuiTabs-indicator': {
height: '3px', // Thicker indicator for better visibility
},
},
})
export default withStyles(styles)(MobileTabs)
+6 -36
View File
@@ -5,7 +5,7 @@ import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { InputBase } from '@mui/material'
import { settingsActions, globalActions } from '../../actions'
import { settingsActions } from '../../actions'
import { alpha as fade, Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
@@ -17,20 +17,13 @@ function SearchBar(props: {
hasConnection: boolean
actions: {
settings: typeof settingsActions
global: typeof globalActions
}
}) {
const { actions, classes, hasConnection, topicFilter } = props
const [hasFocus, setHasFocus] = useState(false)
const inputRef = useRef<HTMLInputElement>()
const onFocus = useCallback(() => {
setHasFocus(true)
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
actions.global.setMobileTab(0)
}
}, [actions])
const onFocus = useCallback(() => setHasFocus(true), [])
const onBlur = useCallback(() => setHasFocus(false), [])
const clearFilter = useCallback(() => {
@@ -64,8 +57,8 @@ function SearchBar(props: {
})
return (
<div className={classes.search} role="search">
<div className={classes.searchIcon} aria-hidden="true">
<div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
</div>
<InputBase
@@ -74,7 +67,6 @@ function SearchBar(props: {
onFocus,
onBlur,
ref: inputRef,
'aria-label': 'Search topics',
}}
onChange={onFilterChange}
placeholder="Search…"
@@ -100,7 +92,6 @@ const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
},
}
}
@@ -139,37 +130,16 @@ const styles = (theme: Theme) => ({
justifyContent: 'center' as 'center',
},
inputRoot: {
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity
color: 'inherit' as 'inherit',
width: '100%',
'& input': {
color: `${theme.palette.common.white} !important`, // Target input element directly
},
},
inputInput: {
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: `${theme.spacing(6)} !important`, // Ensure padding is applied (48px)
paddingLeft: theme.spacing(6),
transition: theme.transitions.create('width'),
width: '100%',
color: `${theme.palette.common.white} !important`, // High contrast white text with priority
fontSize: '16px', // Prevent iOS zoom on focus
'&::placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`, // Semi-transparent white placeholder
opacity: 1,
},
'&::-webkit-input-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
'&::-moz-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
// Improve mobile input handling
[theme.breakpoints.down('md')]: {
fontSize: '16px', // Prevent zoom
WebkitAppearance: 'none',
touchAction: 'manipulation',
},
},
})
+9 -32
View File
@@ -14,7 +14,6 @@ import { connectionActions, globalActions, settingsActions } from '../../actions
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
const styles = (theme: Theme) => ({
title: {
@@ -22,9 +21,6 @@ const styles = (theme: Theme) => ({
[theme.breakpoints.up(750)]: {
display: 'block' as 'block',
},
[theme.breakpoints.up('md')]: {
display: 'block' as 'block',
},
whiteSpace: 'nowrap' as 'nowrap',
},
disconnectIcon: {
@@ -40,17 +36,9 @@ const styles = (theme: Theme) => ({
},
disconnect: {
margin: 'auto 8px auto auto',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
logout: {
margin: 'auto 0 auto 8px',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
disconnectLabel: {
color: theme.palette.primary.contrastText,
@@ -116,7 +104,15 @@ class TitleBar extends React.PureComponent<Props, {}> {
>
Disconnect <CloudOff className={classes.disconnectIcon} />
</Button>
<LogoutButton classes={classes} onLogout={this.handleLogout} />
{isBrowserMode && (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={this.handleLogout}
>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)}
<ConnectionHealthIndicatorAny withBackground={true} />
</Toolbar>
</AppBar>
@@ -124,25 +120,6 @@ class TitleBar extends React.PureComponent<Props, {}> {
}
}
// Separate component to use hooks
function LogoutButton({ classes, onLogout }: { classes: any; onLogout: () => void }) {
const { authDisabled } = useAuth()
if (!isBrowserMode || authDisabled) {
return null
}
return (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={onLogout}
>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
@@ -1,211 +0,0 @@
/**
* LoginDialog Security Tests
*
* Security-focused tests for the Login Page:
* - Error message visibility to users
* - Rate limiting enforcement (anti-brute force)
* - Credential requirement validation
* - Information disclosure prevention
*/
import React from 'react'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { LoginDialog } from './LoginDialog'
import { renderWithProviders, waitFor } from '../utils/spec/testUtils'
// Helper to get elements
const getByText = (text: string) => {
const elements = Array.from(document.querySelectorAll('*'))
return elements.find(el => el.textContent?.includes(text))
}
const getByTestId = (testId: string) => document.querySelector(`[data-testid="${testId}"]`)
describe('LoginDialog Security Tests', () => {
describe('Error Message Visibility (Security)', () => {
it('should display "Invalid credentials" error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify error is visible to user
const errorElement = getByText(errorMessage)
expect(errorElement).to.exist
})
it('should display rate limiting error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify rate limiting error is visible to user
expect(getByText('Too many failed authentication attempts')).to.exist
})
it('should display "Authentication required" error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Please enter your username and password.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify auth required message is visible to user
expect(getByText('Please enter your username and password.')).to.exist
})
it('should display generic authentication failure message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Authentication failed. Please try again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify generic error is visible to user
expect(getByText('Authentication failed. Please try again.')).to.exist
})
})
describe('Rate Limiting Enforcement (Anti-Brute Force)', () => {
it('should disable login button during rate limit countdown', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
// Verify button is disabled to prevent further attempts
const buttons = Array.from(document.querySelectorAll('button'))
const loginButton = buttons.find(b => b.textContent?.match(/Wait \d+s/))
expect(loginButton).to.exist
expect(loginButton?.hasAttribute('disabled')).to.be.true
})
it('should disable input fields during rate limit countdown', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
// Verify inputs are disabled to prevent modification during lockout
const usernameInput = getByTestId('username-input')?.querySelector('input')
const passwordInput = getByTestId('password-input')?.querySelector('input')
expect(usernameInput?.hasAttribute('disabled')).to.be.true
expect(passwordInput?.hasAttribute('disabled')).to.be.true
})
it('should display countdown timer to user during rate limiting', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
// Verify countdown is visible to inform user of lockout duration
const countdownElement = getByText('Please wait')
expect(countdownElement).to.exist
expect(countdownElement?.textContent).to.match(/Please wait \d+ seconds before trying again/i)
})
it('should display both rate limit error and countdown to user', () => {
const mockLogin = () => {}
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
// Verify both error and countdown are visible
expect(getByText(errorMessage)).to.exist
expect(getByText('Please wait 30 seconds before trying again')).to.exist
})
})
describe('Credential Requirement Validation (Prevent Unauthorized Access)', () => {
it('should require both username and password fields to be present', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
// Verify both credential fields exist and are required
const usernameInput = getByTestId('username-input')
const passwordInput = getByTestId('password-input')
expect(usernameInput).to.exist
expect(passwordInput).to.exist
})
it('should require password field to be masked', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
// Verify password is masked (type="password") for security
const passwordInput = getByTestId('password-input')?.querySelector('input')
expect(passwordInput?.getAttribute('type')).to.equal('password')
})
})
describe('Information Disclosure Prevention', () => {
it('should use generic "Invalid credentials" error (no username enumeration)', () => {
const mockLogin = () => {}
// Error doesn't distinguish between invalid username vs invalid password
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify error doesn't leak whether username or password was wrong
const errorElement = getByText(errorMessage)
expect(errorElement).to.exist
expect(errorElement?.textContent).to.not.include('username')
expect(errorElement?.textContent).to.not.include('password')
})
it('should not display sensitive information in error messages', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
// Verify error doesn't contain sensitive data
const errorElement = getByText(errorMessage)
expect(errorElement?.textContent).to.not.include('database')
expect(errorElement?.textContent).to.not.include('server')
expect(errorElement?.textContent).to.not.include('SQL')
expect(errorElement?.textContent).to.not.include('error code')
})
})
})
+1 -68
View File
@@ -2,22 +2,17 @@ import * as React from 'react'
import BooleanSwitch from './BooleanSwitch'
import BrokerStatistics from './BrokerStatistics'
import ChevronRight from '@mui/icons-material/ChevronRight'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { globalActions, settingsActions } from '../../actions'
import { shell } from 'electron'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import {
Button,
Divider,
Drawer,
IconButton,
@@ -80,26 +75,12 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.secondary,
cursor: 'pointer' as 'pointer',
},
mobileButtons: {
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1),
// Only show on mobile
[theme.breakpoints.up('md')]: {
display: 'none' as 'none',
},
},
mobileButton: {
justifyContent: 'flex-start',
},
})
interface Props {
actions: {
settings: typeof settingsActions
global: typeof globalActions
connection: typeof connectionActions
}
autoExpandLimit: number
classes: any
@@ -238,7 +219,6 @@ class Settings extends React.PureComponent<Props, {}> {
</Typography>
<Divider style={{ userSelect: 'none' }} />
</div>
<MobileActionButtons classes={classes} actions={actions} />
<div>
{this.renderAutoExpand()}
{this.renderNodeOrder()}
@@ -258,52 +238,6 @@ class Settings extends React.PureComponent<Props, {}> {
}
}
// Mobile action buttons component (disconnect/logout)
function MobileActionButtons({ classes, actions }: { classes: any; actions: any }) {
const { authDisabled } = useAuth()
const handleLogout = async () => {
// Disconnect first
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()
}
}
return (
<div className={classes.mobileButtons}>
<Button
variant="outlined"
startIcon={<CloudOff />}
onClick={actions.connection.disconnect}
className={classes.mobileButton}
data-testid="mobile-disconnect-button"
>
Disconnect
</Button>
{isBrowserMode && !authDisabled && (
<Button
variant="outlined"
startIcon={<Logout />}
onClick={handleLogout}
className={classes.mobileButton}
data-testid="mobile-logout-button"
>
Logout
</Button>
)}
</div>
)
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.get('autoExpandLimit'),
@@ -320,7 +254,6 @@ const mapDispatchToProps = (dispatch: any) => {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
-477
View File
@@ -1,477 +0,0 @@
/**
* AI Assistant Component
* Provides an interactive AI chat interface for topic exploration
*/
import React, { useState, useCallback, useRef, useEffect } from 'react'
import {
Box,
Paper,
TextField,
IconButton,
Typography,
Chip,
CircularProgress,
Collapse,
Alert,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import SendIcon from '@mui/icons-material/Send'
import SmartToyIcon from '@mui/icons-material/SmartToy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SettingsIcon from '@mui/icons-material/Settings'
import ClearIcon from '@mui/icons-material/Clear'
import { getLLMService, LLMMessage, LLMProvider } from '../../services/llmService'
interface Props {
node?: any
classes: any
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: Date
}
function AIAssistant(props: Props) {
const { node, classes } = props
const [expanded, setExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [configDialogOpen, setConfigDialogOpen] = useState(false)
const [apiKey, setApiKey] = useState('')
const [provider, setProvider] = useState<LLMProvider>('openai')
const messagesEndRef = useRef<HTMLDivElement>(null)
const llmService = getLLMService()
useEffect(() => {
// Initialize provider from service
setProvider(llmService.getProvider())
}, [])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSendMessage = useCallback(
async (messageText?: string) => {
const text = messageText || inputValue.trim()
if (!text) return
// Check if API key is configured
if (!llmService.hasApiKey()) {
setError(`Please configure your ${provider === 'gemini' ? 'Gemini' : 'OpenAI'} API key first`)
setConfigDialogOpen(true)
return
}
setInputValue('')
setError(null)
setLoading(true)
// Add user message to UI
const userMessage: ChatMessage = {
role: 'user',
content: text,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
try {
// Generate topic context if available
const topicContext = node ? llmService.generateTopicContext(node) : undefined
// Send to LLM
const response = await llmService.sendMessage(text, topicContext)
// Add assistant response to UI
const assistantMessage: ChatMessage = {
role: 'assistant',
content: response,
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
} catch (err: unknown) {
const error = err as { message?: string }
setError(error.message || 'Failed to get response')
} finally {
setLoading(false)
}
},
[inputValue, node, llmService]
)
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSendMessage()
}
}
const handleSuggestionClick = (suggestion: string) => {
handleSendMessage(suggestion)
}
const handleClearChat = () => {
setMessages([])
llmService.clearHistory()
setError(null)
}
const handleSaveApiKey = () => {
if (apiKey.trim()) {
llmService.saveApiKey(apiKey.trim())
llmService.saveProvider(provider)
setConfigDialogOpen(false)
setApiKey('')
setError(null)
// Reset the service to use new config
window.location.reload()
}
}
const suggestions = node ? llmService.getQuickSuggestions(node) : []
// Check if API key is available (from localStorage or environment)
const hasApiKey = llmService.hasApiKey()
// Don't render the component at all if no API key is available
if (!hasApiKey) {
return null
}
return (
<Box className={classes.root}>
{/* Header */}
<Box className={classes.header} onClick={() => setExpanded(!expanded)}>
<Box className={classes.headerLeft}>
<SmartToyIcon className={classes.icon} />
<Typography variant="subtitle2" className={classes.title}>
AI Assistant
</Typography>
<Chip label="Beta" size="small" color="primary" className={classes.betaChip} />
</Box>
<Box className={classes.headerRight}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setConfigDialogOpen(true)
}}
className={classes.iconButton}
>
<SettingsIcon fontSize="small" />
</IconButton>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
{/* Chat Interface */}
<Collapse in={expanded}>
<Box className={classes.content}>
{/* Error Alert */}
{error && (
<Alert severity="error" className={classes.alert} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Quick Suggestions */}
{messages.length === 0 && suggestions.length > 0 && (
<Box className={classes.suggestions}>
<Typography variant="caption" color="textSecondary" className={classes.suggestionsTitle}>
Quick questions:
</Typography>
<Box className={classes.suggestionChips}>
{suggestions.slice(0, 4).map((suggestion, idx) => (
<Chip
key={idx}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
className={classes.suggestionChip}
/>
))}
</Box>
</Box>
)}
{/* Messages */}
<Box className={classes.messages}>
{messages.length === 0 && !error && (
<Box className={classes.emptyState}>
<SmartToyIcon className={classes.emptyIcon} />
<Typography variant="body2" color="textSecondary" align="center">
Ask me anything about this topic!
</Typography>
</Box>
)}
{messages.map((msg, idx) => (
<Box
key={idx}
className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}
>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
</Box>
))}
{loading && (
<Box className={classes.loadingBox}>
<CircularProgress size={20} />
<Typography variant="caption" color="textSecondary" sx={{ ml: 1 }}>
Thinking...
</Typography>
</Box>
)}
<div ref={messagesEndRef} />
</Box>
{/* Input */}
<Box className={classes.inputContainer}>
{messages.length > 0 && (
<IconButton size="small" onClick={handleClearChat} className={classes.clearButton}>
<ClearIcon fontSize="small" />
</IconButton>
)}
<TextField
fullWidth
size="small"
placeholder="Ask about this topic..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
className={classes.input}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || loading}
className={classes.sendButton}
>
<SendIcon />
</IconButton>
</Box>
</Box>
</Collapse>
{/* Configuration Dialog */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>AI Assistant Configuration</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>AI Provider</InputLabel>
<Select
value={provider}
label="AI Provider"
onChange={(e) => setProvider(e.target.value as LLMProvider)}
>
<MenuItem value="openai">OpenAI (GPT-3.5 Turbo)</MenuItem>
<MenuItem value="gemini">Google Gemini (Flash)</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" color="textSecondary" paragraph sx={{ mt: 2 }}>
{provider === 'openai' ? (
<>
Get your OpenAI API key from{' '}
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
OpenAI's platform
</a>
.
</>
) : (
<>
Get your Gemini API key from{' '}
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer">
Google AI Studio
</a>
.
</>
)}
</Typography>
<TextField
fullWidth
label={`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API Key`}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'openai' ? 'sk-...' : 'AIza...'}
margin="normal"
helperText="Your API key is stored locally and never sent to our servers"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveApiKey} variant="contained" disabled={!apiKey.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
marginTop: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1.5, 2),
backgroundColor: theme.palette.action.hover,
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
},
headerLeft: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
headerRight: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
},
icon: {
color: theme.palette.primary.main,
},
title: {
fontWeight: 600,
},
betaChip: {
height: '20px',
fontSize: '0.7rem',
},
iconButton: {
padding: theme.spacing(0.5),
},
content: {
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1.5),
},
alert: {
marginBottom: theme.spacing(1),
},
suggestions: {
marginBottom: theme.spacing(1),
},
suggestionsTitle: {
display: 'block',
marginBottom: theme.spacing(0.5),
},
suggestionChips: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
gap: theme.spacing(0.5),
},
suggestionChip: {
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
},
},
messages: {
maxHeight: '300px',
overflowY: 'auto' as 'auto',
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1),
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
gap: theme.spacing(1),
},
emptyIcon: {
fontSize: '3rem',
color: theme.palette.action.disabled,
},
userMessage: {
alignSelf: 'flex-end',
maxWidth: '80%',
padding: theme.spacing(1, 1.5),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: theme.spacing(1.5),
borderBottomRightRadius: theme.spacing(0.5),
},
assistantMessage: {
alignSelf: 'flex-start',
maxWidth: '80%',
padding: theme.spacing(1, 1.5),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.spacing(1.5),
borderBottomLeftRadius: theme.spacing(0.5),
},
messageText: {
whiteSpace: 'pre-wrap' as 'pre-wrap',
wordBreak: 'break-word' as 'break-word',
},
messageTime: {
display: 'block',
marginTop: theme.spacing(0.5),
fontSize: '0.65rem',
},
loadingBox: {
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1),
},
inputContainer: {
display: 'flex',
gap: theme.spacing(1),
alignItems: 'flex-end',
},
clearButton: {
padding: theme.spacing(0.5),
},
input: {
flex: 1,
},
sendButton: {
padding: theme.spacing(1),
},
})
export default withStyles(styles)(AIAssistant)
@@ -41,29 +41,31 @@ function ChartPreview(props: Props) {
const addChartToPanelButton = hasEnoughDataToDisplayDiagrams ? (
<Tooltip title="Add to chart panel">
<span
<ShowChart
ref={chartIconRef}
className={props.classes.icon}
onMouseEnter={mouseOver}
onMouseLeave={mouseOut}
onClick={onClick}
style={{ cursor: 'pointer', display: 'inline-flex' }}
>
<ShowChart className={props.classes.icon} />
</span>
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
) : (
<Tooltip title="Add to chart panel, not enough data for preview">
<span onClick={onClick} style={{ cursor: 'pointer', display: 'inline-flex' }}>
<ShowChart className={props.classes.icon} style={{ color: '#aaa' }} />
</span>
<ShowChart
onClick={onClick}
className={props.classes.icon}
style={{ color: '#aaa' }}
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
)
return (
<div style={{ display: 'inline' }}>
<span data-test-type="ShowChart" data-test={props.literal.path} style={{ display: 'inline-block' }}>
{addChartToPanelButton}
</span>
<span>
{addChartToPanelButton}
<Popper open={open} anchorEl={chartIconRef.current} placement="left-end">
<Fade in={open} timeout={300}>
<Paper style={{ width: '300px' }}>
@@ -75,7 +77,7 @@ function ChartPreview(props: Props) {
</Paper>
</Fade>
</Popper>
</div>
</span>
)
}
-372
View File
@@ -1,372 +0,0 @@
import * as q from '../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { Box, Typography, IconButton, Chip, Tooltip, Button } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { AppState } from '../../reducers'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { sidebarActions, globalActions } from '../../actions'
import Copy from '../helper/Copy'
import Save from '../helper/Save'
import DateFormatter from '../helper/DateFormatter'
import ValueRenderer from './ValueRenderer/ValueRenderer'
import MessageHistory from './ValueRenderer/MessageHistory'
import ActionButtons from './ValueRenderer/ActionButtons'
import DeleteSelectedTopicButton from './ValueRenderer/DeleteSelectedTopicButton'
import { useDecoder } from '../hooks/useDecoder'
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import SimpleBreadcrumb from './SimpleBreadcrumb'
import AIAssistant from './AIAssistant'
interface Props {
node?: q.TreeNode<any>
classes: any
compareMessage?: q.Message
sidebarActions: typeof sidebarActions
globalActions: typeof globalActions
}
function DetailsTab(props: Props) {
const { node, compareMessage, classes } = props
const decodeMessage = useDecoder(node)
const getDecodedValue = useCallback(() => {
return node?.message && decodeMessage(node.message)?.message?.toUnicodeString()
}, [node, decodeMessage])
const getData = () => {
if (node?.message && node.message.payload) {
return node.message.payload.base64Message
}
}
const handleMessageHistorySelect = useCallback(
(message: q.Message) => {
if (message !== compareMessage) {
props.sidebarActions.setCompareMessage(message)
} else {
props.sidebarActions.setCompareMessage(undefined)
}
},
[compareMessage, props.sidebarActions]
)
const deleteTopic = useCallback(
(topic?: q.TreeNode<any>, recursive: boolean = false) => {
if (!topic) {
return
}
props.sidebarActions.clearTopic(topic, recursive)
},
[props.sidebarActions]
)
if (!node) {
return (
<Box className={classes.emptyState}>
<Typography variant="body2" color="textSecondary" align="center">
Select a topic to view details
</Typography>
{/* About Button - always show even when no topic selected */}
<Box className={classes.aboutSection}>
<Button
variant="outlined"
size="small"
startIcon={<Info />}
onClick={() => props.globalActions.toggleAboutDialogVisibility()}
fullWidth
>
About MQTT Explorer
</Button>
</Box>
</Box>
)
}
const [value] =
node && node.message && node.message.payload ? node.message.payload?.format(node.type) : [null, undefined]
const hasValue = Boolean(value)
return (
<Box className={classes.root}>
{/* Topic Section - Breadcrumb with actions */}
<Box className={classes.topicSection}>
<SimpleBreadcrumb node={node} />
<Box className={classes.topicActions}>
<Copy value={node.path()} />
{node.childTopicCount() === 0 && (
<Tooltip title="Delete this topic">
<IconButton size="small" onClick={() => deleteTopic(node, false)} className={classes.iconButton}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{node.childTopicCount() > 0 && (
<Tooltip title="Delete topic and all subtopics">
<IconButton size="small" onClick={() => deleteTopic(node, true)} className={classes.iconButton}>
<DeleteSweepIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{/* Value Section - Simplified layout */}
{hasValue && (
<Box className={classes.valueSection}>
{/* Metadata bar - Date on left, Retained/QoS on right */}
<Box className={classes.metadataBar}>
<Box className={classes.metadataLeft}>
<Typography variant="caption" color="textSecondary">
<DateFormatter date={node.message!.received} />
</Typography>
</Box>
<Box className={classes.metadataRight}>
{node.message?.retain && (
<Chip label="Retained" size="small" variant="outlined" color="primary" className={classes.chip} />
)}
<Chip
label={`QoS ${node.message?.qos ?? 0}`}
size="small"
variant="outlined"
className={classes.chip}
/>
</Box>
</Box>
{/* Action toolbar */}
<Box className={classes.actionToolbar}>
<Typography variant="subtitle2" className={classes.valueTitle}>
Current Value
</Typography>
<Box className={classes.actionButtons}>
<ActionButtons />
</Box>
<Box className={classes.valueActions}>
<Copy getValue={getDecodedValue} />
<Save getData={getData} />
{node.message?.retain && <DeleteSelectedTopicButton />}
</Box>
</Box>
{/* Value Display */}
<Box className={classes.valueDisplay}>
<React.Suspense fallback={<div>Loading...</div>}>
<ValueRenderer treeNode={node} message={node.message!} compareWith={compareMessage} />
</React.Suspense>
</Box>
{/* Message History */}
<Box className={classes.historySection}>
<MessageHistory onSelect={handleMessageHistorySelect} selected={compareMessage} node={node} />
</Box>
{/* Stats Section - Moved to end of value section */}
<Box className={classes.statsSection}>
<Box className={classes.statsGrid}>
<Box className={classes.statItem}>
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
Messages
</Typography>
<Typography variant="h6" className={classes.statValue}>
{node.messages}
</Typography>
</Box>
<Box className={classes.statItem}>
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
Subtopics
</Typography>
<Typography variant="h6" className={classes.statValue}>
{node.childTopicCount()}
</Typography>
</Box>
<Box className={classes.statItem}>
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
Total
</Typography>
<Typography variant="h6" className={classes.statValue}>
{node.leafMessageCount()}
</Typography>
</Box>
</Box>
</Box>
</Box>
)}
{/* AI Assistant - Always available when a node is selected */}
{node && <AIAssistant node={node} />}
{/* About Section - always visible at bottom */}
<Box className={classes.aboutSection}>
<Button
variant="outlined"
size="small"
startIcon={<Info />}
onClick={() => props.globalActions.toggleAboutDialogVisibility()}
fullWidth
>
About MQTT Explorer
</Button>
</Box>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(3),
[theme.breakpoints.down('sm')]: {
gap: theme.spacing(2),
},
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
padding: theme.spacing(3),
gap: theme.spacing(3),
},
aboutSection: {
marginTop: theme.spacing(3),
paddingTop: theme.spacing(2),
borderTop: `1px solid ${theme.palette.divider}`,
},
aboutSection: {
marginTop: theme.spacing(3),
paddingTop: theme.spacing(2),
borderTop: `1px solid ${theme.palette.divider}`,
},
// Topic section
topicSection: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: theme.spacing(1),
paddingBottom: theme.spacing(2),
borderBottom: `1px solid ${theme.palette.divider}`,
},
topicActions: {
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
flexShrink: 0,
},
iconButton: {
padding: theme.spacing(0.5),
},
// Stats section
statsSection: {
marginTop: theme.spacing(2),
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: theme.spacing(1.5),
[theme.breakpoints.down('sm')]: {
gap: theme.spacing(1),
},
},
statItem: {
display: 'flex',
flexDirection: 'column' as 'column',
alignItems: 'center',
padding: theme.spacing(1.5, 1),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
gap: theme.spacing(0.5),
},
statLabel: {
fontSize: '0.75rem',
fontWeight: 500,
textTransform: 'uppercase' as 'uppercase',
letterSpacing: '0.5px',
},
statValue: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1,
},
// Value section
valueSection: {
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(2),
},
metadataBar: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
padding: theme.spacing(1),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
},
metadataLeft: {
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
flexWrap: 'wrap' as 'wrap',
},
metadataRight: {
display: 'flex',
alignItems: 'center',
},
chip: {
height: '24px',
},
actionToolbar: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
},
valueTitle: {
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
letterSpacing: '0.5px',
flexShrink: 0,
},
actionButtons: {
display: 'flex',
alignItems: 'center',
flex: 1,
},
valueActions: {
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
},
valueDisplay: {
marginTop: theme.spacing(1),
},
historySection: {
marginTop: theme.spacing(1),
},
})
const mapStateToProps = (state: AppState) => {
return {
compareMessage: state.sidebar.get('compareMessage'),
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(DetailsTab))
-53
View File
@@ -1,53 +0,0 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const Publish = React.lazy(() => import('./Publish/Publish'))
interface Props {
connectionId?: string
classes: any
}
function PublishTab(props: Props) {
const { classes } = props
return (
<Box className={classes.root}>
<Box className={classes.header}>
<Typography variant="subtitle2" className={classes.title}>
Publish Message
</Typography>
<Typography variant="caption" color="textSecondary">
Send messages to MQTT topics
</Typography>
</Box>
<React.Suspense fallback={<div>Loading...</div>}>
<Publish connectionId={props.connectionId} />
</React.Suspense>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(2),
},
header: {
marginBottom: theme.spacing(1),
},
title: {
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
letterSpacing: '0.5px',
marginBottom: theme.spacing(0.5),
},
})
export default withStyles(styles)(PublishTab)
+31 -81
View File
@@ -1,24 +1,28 @@
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 { AccordionDetails } from '@mui/material'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { settingsActions, sidebarActions } from '../../actions'
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'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import DetailsTab from './DetailsTab'
import PublishTab from './PublishTab'
const throttle = require('lodash.throttle')
const Publish = React.lazy(() => import('./Publish/Publish'))
interface Props {
nodePath?: string
tree?: q.Tree<TopicViewModel>
actions: typeof sidebarActions
globalActions: typeof globalActions
settingsActions: typeof settingsActions
classes: any
connectionId?: string
@@ -45,55 +49,27 @@ function useUpdateNodeWhenNodeReceivesUpdates(node?: q.TreeNode<any>) {
}, [node])
}
function SidebarNew(props: Props) {
function Sidebar(props: Props) {
const { classes, tree, nodePath } = props
const node = usePollingToFetchTreeNode(tree, nodePath || '')
useUpdateNodeWhenNodeReceivesUpdates(node)
const [tabValue, setTabValue] = useState(0)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue)
}
// On mobile, don't show tabs (mobile already has Topics/Details tabs at app level)
// Just show the content directly
if (isMobile) {
return (
<div id="Sidebar" className={classes.root}>
<Box className={classes.mobileContent}>
<DetailsTab node={node} />
</Box>
</div>
)
}
// Desktop: show tabs for Details/Publish
return (
<div id="Sidebar" className={classes.root}>
<Box className={classes.tabsContainer}>
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
className={classes.tabs}
>
<Tab label="Details" className={classes.tab} />
<Tab label="Publish" className={classes.tab} />
</Tabs>
</Box>
<Box className={classes.tabContent}>
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
<DetailsTab node={node} />
</Box>
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
<PublishTab connectionId={props.connectionId} />
</Box>
</Box>
<div id="Sidebar" className={classes.drawer}>
<div>
<TopicPanel node={node} />
<ValuePanelAny lastUpdate={node ? node.lastUpdate : 0} />
<Panel>
<span>Publish</span>
<Publish connectionId={props.connectionId} />
</Panel>
<Panel detailsHidden={!node}>
<span>Stats</span>
<AccordionDetails className={classes.details}>
<NodeStats node={node} />
</AccordionDetails>
</Panel>
</div>
</div>
)
}
@@ -109,44 +85,18 @@ const mapStateToProps = (state: AppState) => {
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
height: '100%',
width: '100%',
drawer: {
display: 'block' as 'block',
},
tabsContainer: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
},
tabs: {
minHeight: '48px',
},
tab: {
minHeight: '48px',
fontSize: '14px',
fontWeight: 500,
textTransform: 'none' as 'none',
padding: theme.spacing(1.5, 2),
},
tabContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
padding: theme.spacing(2),
},
mobileContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
padding: theme.spacing(2),
details: {
padding: '0px 16px 8px 8px',
display: 'block',
},
})
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(SidebarNew))
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Sidebar))
@@ -1,87 +0,0 @@
import React from 'react'
import * as q from '../../../../backend/src/Model'
import { Link } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { treeActions } from '../../actions'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
interface Props {
node?: q.TreeNode<any>
classes: any
actions: typeof treeActions
}
function SimpleBreadcrumb(props: Props) {
const { node, classes, actions } = props
if (!node) {
return null
}
const branch = node.branch()
const breadcrumbNodes = branch
.map(n => n.sourceEdge)
.filter(edge => Boolean(edge) && edge?.target)
.map(edge => ({ name: edge?.name || '', target: edge!.target }))
.filter(item => item.name !== '')
if (breadcrumbNodes.length === 0) {
return null
}
return (
<div className={classes.breadcrumbContainer}>
{breadcrumbNodes.map((item, index) => (
<span key={item.target.hash()}>
{index > 0 && <span className={classes.separator}> / </span>}
<Link
component="button"
variant="h6"
className={classes.breadcrumbLink}
onClick={() => actions.selectTopic(item.target)}
underline="hover"
>
{item.name}
</Link>
</span>
))}
</div>
)
}
const styles = (theme: Theme) => ({
breadcrumbContainer: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
alignItems: 'center',
gap: 0,
},
breadcrumbLink: {
fontSize: '1rem',
fontWeight: 500,
color: theme.palette.text.primary,
cursor: 'pointer',
textAlign: 'left' as 'left',
border: 'none',
background: 'none',
padding: 0,
lineHeight: 1.5,
'&:hover': {
color: theme.palette.primary.main,
},
},
separator: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default connect(null, mapDispatchToProps)(withStyles(styles)(SimpleBreadcrumb))
@@ -24,7 +24,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
const selectOption = useCallback(
(decoder: MessageDecoder, format: string) => {
if (!node || !node.viewModel) {
if (!node) {
return
}
@@ -55,7 +55,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
return (
<Button onClick={handleToggle}>
{props.node?.viewModel?.decoder?.format ?? props.node?.type}
{props.node?.viewModel.decoder?.format ?? props.node?.type}
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
{({ TransitionProps, placement }) => (
<Grow
@@ -1,8 +1,8 @@
import React, { useCallback } from 'react'
import Code from '@mui/icons-material/Code'
import Reorder from '@mui/icons-material/Reorder'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import ToggleButton from '@mui/lab/ToggleButton'
import ToggleButtonGroup from '@mui/lab/ToggleButtonGroup'
import { settingsActions } from '../../../actions'
import { Tooltip } from '@mui/material'
import { withStyles } from '@mui/styles'
@@ -36,17 +36,15 @@ function ActionButtons(props: {
>
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
<Tooltip title="Show difference between the current and the last message">
<span className={props.classes.buttonContent}>
<span>
<Code className={props.classes.toggleButtonIcon} />
<span className={props.classes.buttonText}>Diff</span>
</span>
</Tooltip>
</ToggleButton>
<ToggleButton className={props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
<Tooltip title="Raw / formatted JSON / formatted sparkplugb protojson">
<span className={props.classes.buttonContent}>
<span>
<Reorder className={props.classes.toggleButtonIcon} />
<span className={props.classes.buttonText}>Raw</span>
</span>
</Tooltip>
</ToggleButton>
@@ -57,20 +55,9 @@ function ActionButtons(props: {
const styles = (theme: Theme) => ({
toggleButton: {
height: '36px',
padding: theme.spacing(0.5, 1.5),
},
toggleButtonIcon: {
verticalAlign: 'middle',
fontSize: '1.25rem',
},
buttonContent: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
},
buttonText: {
fontSize: '0.875rem',
textTransform: 'none' as 'none',
},
})
@@ -52,21 +52,8 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
return null
}
// On mobile, the expand button has its own click handler separate from topic selection
// On desktop, clicking anywhere (including expander) selects and toggles via didClickTitle
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
const onClick = isMobile ? props.toggleCollapsed : undefined
return (
<span
key="expander"
className={props.classes.expander}
onClick={onClick}
role="button"
aria-label={props.collapsed ? 'Expand topic' : 'Collapse topic'}
aria-expanded={!props.collapsed}
tabIndex={isMobile ? 0 : -1}
>
<span key="expander" className={props.classes.expander} onClick={props.toggleCollapsed}>
{props.collapsed ? '▶' : '▼'}
</span>
)
@@ -96,39 +83,27 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
)
}
const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
fontSize: isMobile ? '15px' : 'inherit', // Slightly larger on mobile
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
fontSize: isMobile ? '16px' : 'inherit', // Base 16px on mobile to prevent zoom
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: isMobile ? theme.spacing(1) : theme.spacing(0.25), // Larger touch area
paddingLeft: isMobile ? theme.spacing(0.5) : 0,
minWidth: isMobile ? '32px' : 'auto', // 40px total width on mobile for touch
display: 'inline-block' as 'inline-block',
textAlign: 'center' as 'center',
userSelect: 'none' as 'none',
fontSize: isMobile ? '18px' : 'inherit', // Larger icon on mobile
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
fontSize: isMobile ? '14px' : 'inherit',
},
}
}
const styles = (theme: Theme) => ({
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: theme.spacing(0.25),
userSelect: 'none' as 'none',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
},
})
export default withStyles(styles)(memo(TreeNodeTitle))
+3 -19
View File
@@ -61,22 +61,10 @@ function TreeNodeComponent(props: Props) {
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
if (isMobile) {
// Mobile: Only select the topic (no toggle)
// Expanding is handled by the separate expand button click
didSelectTopic()
// Switch to details tab on mobile after selecting a topic
actions.setMobileTab(1)
} else {
// Desktop: Original behavior - select AND toggle (click anywhere works)
didSelectTopic()
setCollapsedOverride(!isCollapsed)
}
didSelectTopic()
setCollapsedOverride(!isCollapsed)
},
[isCollapsed, didSelectTopic, actions]
[isCollapsed, didSelectTopic]
)
const toggleCollapsed = useCallback(
@@ -134,10 +122,6 @@ function TreeNodeComponent(props: Props) {
onClick={didClickTitle}
tabIndex={-1}
onKeyDown={deleteTopicCallback}
role="treeitem"
aria-selected={selected}
aria-expanded={!isCollapsed}
aria-label={`Topic: ${name || treeNode.sourceEdge?.name || 'root'}`}
>
<TreeNodeTitle
lastUpdate={treeNode.lastUpdate}
+5 -15
View File
@@ -2,8 +2,6 @@ import { blueGrey } from '@mui/material/colors'
import { Theme } from '@mui/material/styles'
export const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
animationLight: {
willChange: 'auto',
@@ -27,7 +25,7 @@ export const styles = (theme: Theme) => {
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
padding: isMobile ? '1px 0px' : '1px 0px 0px 0px',
padding: '1px 0px 0px 0px',
},
topicSelect: {
float: 'right' as 'right',
@@ -36,7 +34,7 @@ export const styles = (theme: Theme) => {
marginTop: '-1px',
},
subnodes: {
marginLeft: isMobile ? theme.spacing(2) : theme.spacing(1.5), // Increased indentation on mobile
marginLeft: theme.spacing(1.5),
},
selected: {
backgroundColor: (theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
@@ -44,23 +42,15 @@ export const styles = (theme: Theme) => {
hover: {},
title: {
borderRadius: '4px',
lineHeight: isMobile ? '1.3em' : '1em',
lineHeight: '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
minHeight: isMobile ? '40px' : '14px', // 44px touch target on mobile (WCAG AA minimum)
height: 'auto' as 'auto',
padding: isMobile ? '8px 8px' : '1px 4px 0 4px', // Reduced padding, still touch-friendly
height: '14px',
padding: '1px 4px 0 4px',
margin: '1px 0px',
fontSize: isMobile ? '16px' : 'inherit', // Prevent iOS zoom on focus
cursor: 'pointer' as 'pointer',
'&:hover': {
backgroundColor: theme.palette.mode === 'light' ? blueGrey[100] : theme.palette.primary.light,
},
// Better touch feedback on mobile
[theme.breakpoints.down('md')]: {
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
},
},
}
}
+13 -71
View File
@@ -13,9 +13,6 @@ const MovingAverage = require('moving-average')
const averagingTimeInterval = 10 * 1000
const average = MovingAverage(averagingTimeInterval)
// Mobile viewport breakpoint - matches CSS media queries in ContentView
const MOBILE_BREAKPOINT = 768
declare var window: any
interface Props {
@@ -29,7 +26,6 @@ interface Props {
interface State {
lastUpdate: number
isMobile: boolean
}
function useArrowKeyEventHandler(actions: typeof treeActions) {
@@ -57,16 +53,12 @@ function useArrowKeyEventHandler(actions: typeof treeActions) {
class TreeComponent extends React.PureComponent<Props, State> {
private updateTimer?: any
private resizeTimer?: any
private perf: number = 0
private renderTime = 0
constructor(props: any) {
super(props)
this.state = {
lastUpdate: 0,
isMobile: typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT,
}
this.state = { lastUpdate: 0 }
}
private keyEventHandler = useArrowKeyEventHandler(this.props.actions)
@@ -74,27 +66,6 @@ class TreeComponent extends React.PureComponent<Props, State> {
average.push(Date.now(), ms)
}
private handleResize = () => {
// Debounce resize events - only update after user stops resizing
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
}
this.resizeTimer = setTimeout(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT
if (this.state.isMobile !== isMobile) {
this.setState({ isMobile })
}
this.resizeTimer = undefined
}, 150) // Wait 150ms after last resize event
}
public componentDidMount() {
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.handleResize)
}
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.tree !== nextProps.tree) {
if (this.props.tree) {
@@ -109,18 +80,6 @@ class TreeComponent extends React.PureComponent<Props, State> {
public componentWillUnmount() {
this.props.tree && this.props.tree.didUpdate.unsubscribe(this.throttledTreeUpdate)
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.handleResize)
}
// Clean up any pending timers to prevent memory leaks
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
this.resizeTimer = undefined
}
if (this.updateTimer) {
clearTimeout(this.updateTimer)
this.updateTimer = undefined
}
}
public throttledTreeUpdate = () => {
@@ -168,47 +127,30 @@ class TreeComponent extends React.PureComponent<Props, State> {
return null
}
const { isMobile } = this.state
const style: React.CSSProperties = {
lineHeight: '1.1',
cursor: 'default',
overflowY: 'scroll',
overflowX: isMobile ? 'auto' : 'hidden', // Enable horizontal scrolling on mobile
overflowX: 'hidden',
height: '100%',
width: '100%',
outline: '24px black !important',
paddingBottom: '16px', // avoid conflict with chart panel Resizer
// Scroll snap to default position on mobile
...(isMobile && {
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
}),
}
const treeNode = (
<TreeNode
key={tree.hash()}
isRoot={true}
treeNode={tree}
name={this.props.host}
collapsed={false}
settings={this.props.settings}
lastUpdate={tree.lastUpdate}
actions={this.props.actions}
selectTopicAction={this.props.actions.selectTopic}
/>
)
return (
<div style={style} tabIndex={0} onKeyDown={this.keyEventHandler}>
{isMobile ? (
<div style={{ scrollSnapAlign: 'start', minWidth: '100%' }}>
{treeNode}
</div>
) : (
treeNode
)}
<TreeNode
key={tree.hash()}
isRoot={true}
treeNode={tree}
name={this.props.host}
collapsed={false}
settings={this.props.settings}
lastUpdate={tree.lastUpdate}
actions={this.props.actions}
selectTopicAction={this.props.actions.selectTopic}
/>
</div>
)
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { withStyles } from '@mui/styles'
import { updateNotifierActions } from '../actions'
import { Button, IconButton, Modal, Paper, Snackbar, SnackbarContent, Typography } from '@mui/material'
import { rendererRpc, getAppVersion } from '../eventBus'
import { rendererRpc, getAppVersion } from '../../../events'
interface Props {
showUpdateNotification: boolean
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props {
*/
function ClearAdornment(props: Props) {
const theme = useTheme()
if (!props.value) {
return null
}
+4 -2
View File
@@ -5,7 +5,9 @@ import FileCopy from '@mui/icons-material/FileCopy'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions } from '../../actions'
import copyTextFallback from '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 {
@@ -17,7 +19,7 @@ async function copyToClipboard(text: string): Promise<boolean> {
} catch (error) {
console.warn('Clipboard API failed, using fallback:', error)
}
// Fallback to copy-text-to-clipboard library
return copyTextFallback(text)
}
@@ -39,9 +39,9 @@ class CustomIconButton extends React.PureComponent<Props, {}> {
public render() {
return (
<IconButton
className={this.props.classes.button}
style={this.props.style}
<IconButton
className={this.props.classes.button}
style={this.props.style}
onClick={this.onClick}
data-testid={this.props['data-testid']}
>
+2 -44
View File
@@ -5,58 +5,16 @@ import CustomIconButton from './CustomIconButton'
import { SaveAlt } from '@mui/icons-material'
import { bindActionCreators } from 'redux'
import { rendererRpc, writeToFile } from '../../eventBus'
import { rendererRpc, writeToFile } from '../../../../events'
import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest'
import { isBrowserMode } from '../../utils/browserMode'
import { globalActions } from '../../actions'
/**
* Download a file in browser mode using blob URL
* @param data Base64-encoded file data
* @param filename Filename for the download
* @returns The filename that was downloaded
*/
function downloadFileInBrowser(data: string, filename: string): string {
// Decode base64 data
const binaryString = atob(data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
// Create blob and download
const blob = new Blob([bytes], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return filename
}
export async function saveToFile(data: string): Promise<string | undefined> {
const rejectReasons = {
errorWritingFile: 'Error writing file',
}
// In browser mode, use browser download
if (isBrowserMode) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `mqtt-message-${timestamp}.bin`
try {
downloadFileInBrowser(data, filename)
return filename
} catch (error) {
throw rejectReasons.errorWritingFile
}
}
// In Electron mode, use native file dialog
const { canceled, filePath } = await rendererRpc.call(makeSaveDialogRpc(), {
securityScopedBookmarks: true,
})
@@ -109,7 +67,7 @@ class Save extends React.PureComponent<Props, State> {
)
return (
<CustomIconButton onClick={this.handleClick} tooltip="Save to file" data-testid="save-button">
<CustomIconButton onClick={this.handleClick} tooltip="Save to file">
<div style={{ marginTop: '2px' }}>{icon}</div>
</CustomIconButton>
)
-13
View File
@@ -1,13 +0,0 @@
import * as React from 'react'
interface AuthContextType {
authDisabled: boolean
}
export const AuthContext = React.createContext<AuthContextType>({
authDisabled: false,
})
export function useAuth() {
return React.useContext(AuthContext)
}
-134
View File
@@ -1,134 +0,0 @@
/**
* Event bus abstraction layer
* Provides the correct rendererRpc and rendererEvents implementation based on runtime environment
* - In browser mode: uses Socket.IO-based event bus
* - In Electron mode: uses IPC-based event bus
*
* This module uses dynamic imports to avoid bundling unused dependencies.
*/
import { isBrowserMode } from './utils/browserMode'
import type { Rpc } from '../../events/EventSystem/Rpc'
import type { EventBusInterface } from '../../events/EventSystem/EventBusInterface'
let rendererRpcInstance: Rpc<any> | null = null
let rendererEventsInstance: EventBusInterface | null = null
let backendRpcInstance: Rpc<any> | null = null
let backendEventsInstance: EventBusInterface | null = null
/**
* Get the renderer RPC instance
* Lazy-loads the appropriate implementation based on environment
*/
export function getRendererRpc(): Rpc<any> {
if (rendererRpcInstance) {
return rendererRpcInstance
}
if (isBrowserMode) {
// Dynamic import for browser mode
const browserEventBus = require('./browserEventBus')
rendererRpcInstance = browserEventBus.rendererRpc
} else {
// Dynamic import for Electron mode
const electronEventBus = require('../../events/EventSystem/EventBus')
rendererRpcInstance = electronEventBus.rendererRpc
}
return rendererRpcInstance
}
/**
* Get the renderer events instance
* Lazy-loads the appropriate implementation based on environment
*/
export function getRendererEvents(): EventBusInterface {
if (rendererEventsInstance) {
return rendererEventsInstance
}
if (isBrowserMode) {
// Dynamic import for browser mode
const browserEventBus = require('./browserEventBus')
rendererEventsInstance = browserEventBus.rendererEvents
} else {
// Dynamic import for Electron mode
const electronEventBus = require('../../events/EventSystem/EventBus')
rendererEventsInstance = electronEventBus.rendererEvents
}
return rendererEventsInstance
}
/**
* Get the backend RPC instance (for compatibility)
*/
export function getBackendRpc(): Rpc<any> {
if (backendRpcInstance) {
return backendRpcInstance
}
if (isBrowserMode) {
// In browser mode, backend is accessed via socket.io
const browserEventBus = require('./browserEventBus')
backendRpcInstance = browserEventBus.backendRpc
} else {
// In Electron mode, backend RPC uses IPC
const electronEventBus = require('../../events/EventSystem/EventBus')
backendRpcInstance = electronEventBus.backendRpc
}
return backendRpcInstance
}
/**
* Get the backend events instance (for compatibility)
*/
export function getBackendEvents(): EventBusInterface {
if (backendEventsInstance) {
return backendEventsInstance
}
if (isBrowserMode) {
// In browser mode, backend is accessed via socket.io
const browserEventBus = require('./browserEventBus')
backendEventsInstance = browserEventBus.backendEvents
} else {
// In Electron mode, backend events use IPC
const electronEventBus = require('../../events/EventSystem/EventBus')
backendEventsInstance = electronEventBus.backendEvents
}
return backendEventsInstance
}
// Export as named constants for convenience (lazy-loaded on first access)
export const rendererRpc = new Proxy({} as Rpc<any>, {
get(target, prop) {
return getRendererRpc()[prop as keyof Rpc<any>]
}
})
export const rendererEvents = new Proxy({} as EventBusInterface, {
get(target, prop) {
return getRendererEvents()[prop as keyof EventBusInterface]
}
})
export const backendRpc = new Proxy({} as Rpc<any>, {
get(target, prop) {
return getBackendRpc()[prop as keyof Rpc<any>]
}
})
export const backendEvents = new Proxy({} as EventBusInterface, {
get(target, prop) {
return getBackendEvents()[prop as keyof EventBusInterface]
}
})
// Re-export all event definitions that are shared
export * from '../../events/Events'
export * from '../../events/EventsV2'
export * from '../../events/EventSystem/EventDispatcher'
export * from '../../events/EventSystem/EventBusInterface'
+6 -3
View File
@@ -2,16 +2,19 @@ import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import App from './components/App'
import Demo from './components/Demo'
import { AppState } from './reducers'
import reducers, { AppState } from './reducers'
import { thunk as reduxThunk } from 'redux-thunk'
import { applyMiddleware, compose, createStore } from 'redux'
import { batchDispatchMiddleware } from 'redux-batched-actions'
import { connect, Provider } from 'react-redux'
import { ThemeProvider } from '@mui/material/styles'
import { ThemeProvider as LegacyThemeProvider } from '@mui/styles'
import './utils/tracking'
import { themes } from './theme'
import { BrowserAuthWrapper } from './components/BrowserAuthWrapper'
import { store } from './store'
import './autoConnectHandler' // Initialize auto-connect handling
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
function ApplicationRenderer(props: { theme: 'light' | 'dark' }) {
const theme = props.theme === 'light' ? themes.lightTheme : themes.darkTheme
+4
View File
@@ -27,6 +27,7 @@ export interface ConnectionOptions {
clientKey?: CertificateParameters
clientId?: string
subscriptions: Array<Subscription>
order?: number
}
export function toMqttConnection(options: ConnectionOptions): MqttOptions | undefined {
@@ -71,6 +72,7 @@ export function createEmptyConnection(): ConnectionOptions {
host: '',
port: 1883,
protocol: 'mqtt',
order: Date.now(),
}
}
@@ -82,12 +84,14 @@ export function makeDefaultConnections() {
id: 'mqtt.eclipseprojects.io',
name: 'mqtt.eclipseprojects.io',
host: 'mqtt.eclipseprojects.io',
order: 0,
},
'test.mosquitto.org': {
...createEmptyConnection(),
id: 'test.mosquitto.org',
name: 'test.mosquitto.org',
host: 'test.mosquitto.org',
order: 1,
},
}
}
+46
View File
@@ -26,6 +26,7 @@ export type Action =
| ToggleCertificateSettings
| DeleteSubscription
| AddSubscription
| MoveConnection
export enum ActionTypes {
CONNECTION_MANAGER_SET_CONNECTIONS = 'CONNECTION_MANAGER_SET_CONNECTIONS',
@@ -37,6 +38,7 @@ export enum ActionTypes {
CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS = 'CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS',
CONNECTION_MANAGER_ADD_SUBSCRIPTION = 'CONNECTION_MANAGER_ADD_SUBSCRIPTION',
CONNECTION_MANAGER_DELETE_SUBSCRIPTION = 'CONNECTION_MANAGER_DELETE_SUBSCRIPTION',
CONNECTION_MANAGER_MOVE_CONNECTION = 'CONNECTION_MANAGER_MOVE_CONNECTION',
}
export interface SetConnections {
@@ -85,6 +87,12 @@ export interface ToggleCertificateSettings {
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS
}
export interface MoveConnection {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION
connectionId: string
direction: 'up' | 'down'
}
export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_SET_CONNECTIONS: setConnections,
CONNECTION_MANAGER_SELECT_CONNECTION: selectConnection,
@@ -95,6 +103,7 @@ export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS: toggleCertificateSettings,
CONNECTION_MANAGER_DELETE_SUBSCRIPTION: deleteSubscription,
CONNECTION_MANAGER_ADD_SUBSCRIPTION: addSubscription,
CONNECTION_MANAGER_MOVE_CONNECTION: moveConnection,
})
function setConnections(state: ConnectionManagerState, action: SetConnections): ConnectionManagerState {
@@ -222,3 +231,40 @@ function updateConnection(state: ConnectionManagerState, action: UpdateConnectio
},
}
}
function moveConnection(state: ConnectionManagerState, action: MoveConnection): ConnectionManagerState {
const connections = Object.values(state.connections).sort((a, b) => (a.order || 0) - (b.order || 0))
const currentIndex = connections.findIndex(c => c.id === action.connectionId)
if (currentIndex === -1) {
return state
}
const targetIndex = action.direction === 'up' ? currentIndex - 1 : currentIndex + 1
// Can't move beyond bounds
if (targetIndex < 0 || targetIndex >= connections.length) {
return state
}
// Swap order values
const currentConnection = connections[currentIndex]
const targetConnection = connections[targetIndex]
const currentOrder = currentConnection.order || 0
const targetOrder = targetConnection.order || 0
return {
...state,
connections: {
...state.connections,
[currentConnection.id]: {
...currentConnection,
order: targetOrder,
},
[targetConnection.id]: {
...targetConnection,
order: currentOrder,
},
},
}
}
-16
View File
@@ -11,8 +11,6 @@ export enum ActionTypes {
toggleSettingsVisibility = 'TOGGLE_SETTINGS_VISIBILITY',
requestConfirmation = 'REQUEST_CONFIRMATION',
removeConfirmationRequest = 'REMOVE_CONFIRMATION_REQUEST',
toggleAboutDialogVisibility = 'TOGGLE_ABOUT_DIALOG_VISIBILITY',
setMobileTab = 'SET_MOBILE_TAB',
}
export interface ConfirmationRequest {
@@ -28,7 +26,6 @@ export interface GlobalAction extends Action {
error?: string
notification?: string
confirmationRequest?: ConfirmationRequest
mobileTab?: number
}
interface GlobalStateInterface {
@@ -39,8 +36,6 @@ interface GlobalStateInterface {
launching: boolean
settingsVisible: boolean
confirmationRequests: Array<ConfirmationRequest>
aboutDialogVisible: boolean
mobileTab: number // 0 = topics, 1 = details, 2 = publish, 3 = charts
}
export type GlobalState = Record<GlobalStateInterface>
@@ -53,8 +48,6 @@ const initialStateFactory = Record<GlobalStateInterface>({
launching: true,
settingsVisible: false,
confirmationRequests: [],
aboutDialogVisible: false,
mobileTab: 0,
})
export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> = (
@@ -70,9 +63,6 @@ export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> =
case ActionTypes.toggleSettingsVisibility:
return state.set('settingsVisible', !state.get('settingsVisible'))
case ActionTypes.toggleAboutDialogVisibility:
return state.set('aboutDialogVisible', !state.get('aboutDialogVisible'))
case ActionTypes.showError:
return state.set('error', action.error)
@@ -103,12 +93,6 @@ export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> =
state.get('confirmationRequests').filter(a => a !== action.confirmationRequest)
)
case ActionTypes.setMobileTab:
if (action.mobileTab === undefined) {
return state
}
return state.set('mobileTab', action.mobileTab)
default:
return state
}
@@ -0,0 +1,139 @@
import 'mocha'
import { expect } from 'chai'
import { connectionManagerReducer, ConnectionManagerState, ActionTypes } from '../ConnectionManager'
import { createEmptyConnection } from '../../model/ConnectionOptions'
describe('ConnectionManager - moveConnection', () => {
it('should move connection up', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const connection3 = { ...createEmptyConnection(), id: 'conn3', name: 'Connection 3', order: 2 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
conn3: connection3,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(1)
expect(newState.connections.conn2.order).to.equal(0)
expect(newState.connections.conn3.order).to.equal(2)
})
it('should move connection down', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const connection3 = { ...createEmptyConnection(), id: 'conn3', name: 'Connection 3', order: 2 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
conn3: connection3,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'down' as 'down',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(2)
expect(newState.connections.conn3.order).to.equal(1)
})
it('should not move connection up when already at top', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn1',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(1)
})
it('should not move connection down when already at bottom', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const connection2 = { ...createEmptyConnection(), id: 'conn2', name: 'Connection 2', order: 1 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
conn2: connection2,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'conn2',
direction: 'down' as 'down',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
expect(newState.connections.conn2.order).to.equal(1)
})
it('should handle non-existent connection', () => {
const connection1 = { ...createEmptyConnection(), id: 'conn1', name: 'Connection 1', order: 0 }
const initialState: ConnectionManagerState = {
connections: {
conn1: connection1,
},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
const action = {
type: ActionTypes.CONNECTION_MANAGER_MOVE_CONNECTION,
connectionId: 'nonexistent',
direction: 'up' as 'up',
}
const newState = connectionManagerReducer(initialState, action)
expect(newState.connections.conn1.order).to.equal(0)
})
})
-507
View File
@@ -1,507 +0,0 @@
/**
* LLM Service for interacting with topics
* Provides AI assistance to help users understand and interact with MQTT topics
*/
import axios, { AxiosInstance } from 'axios'
export interface LLMMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export type LLMProvider = 'openai' | 'gemini'
export interface LLMServiceConfig {
apiKey?: string
apiEndpoint?: string
model?: string
provider?: LLMProvider
neighboringTopicsTokenLimit?: number
}
export class LLMService {
private axiosInstance: AxiosInstance
private model: string
private provider: LLMProvider
private conversationHistory: LLMMessage[] = []
private neighboringTopicsTokenLimit: number
constructor(config: LLMServiceConfig = {}) {
const apiKey = config.apiKey || this.getApiKeyFromStorage() || this.getApiKeyFromEnv()
this.provider = config.provider || this.getProviderFromStorage() || this.getProviderFromEnv() || 'openai'
this.neighboringTopicsTokenLimit = config.neighboringTopicsTokenLimit || this.getNeighboringTopicsTokenLimitFromEnv() || 100
// Set default endpoint and model based on provider
let baseURL = config.apiEndpoint
if (!baseURL) {
baseURL = this.provider === 'gemini'
? 'https://generativelanguage.googleapis.com/v1beta'
: 'https://api.openai.com/v1'
}
this.model = config.model || (this.provider === 'gemini' ? 'gemini-1.5-flash-latest' : 'gpt-3.5-turbo')
// Configure headers based on provider
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (apiKey) {
if (this.provider === 'gemini') {
// Gemini uses API key as query parameter, not header
// Will be added to URL in sendMessage
} else {
headers.Authorization = `Bearer ${apiKey}`
}
}
this.axiosInstance = axios.create({
baseURL,
headers,
timeout: 30000,
})
// Initialize with system message that sets MQTT and automation context
this.conversationHistory.push({
role: 'system',
content: `You are an expert AI assistant specializing in MQTT (Message Queuing Telemetry Transport) protocol and home/industrial automation systems.
**Your Core Expertise:**
- MQTT protocol: topics, QoS levels, retained messages, wildcards, last will and testament
- IoT and smart home ecosystems: devices, sensors, actuators, and controllers
- Home automation platforms: Home Assistant, openHAB, Node-RED, MQTT brokers
- Common MQTT topic patterns and naming conventions (e.g., zigbee2mqtt, tasmota, homie)
- Data formats: JSON payloads, binary data, sensor readings, state messages
- Time-series data analysis and pattern recognition
- Troubleshooting connectivity, message delivery, and data quality issues
**Your Communication Style:**
- Be concise and practical - focus on actionable insights
- Use clear technical language appropriate for users familiar with MQTT
- When analyzing data, identify patterns, anomalies, or potential issues
- Suggest practical next steps or automations when relevant
- Reference common MQTT ecosystems and standards when applicable
**Context You Receive:**
Users will ask about specific MQTT topics and their data. You'll receive:
- Topic path (the MQTT topic hierarchy)
- Current value and message payload
- Related/neighboring topics with their values
- Metadata (message count, subtopics, retained status)
**Your Goal:**
Help users understand their MQTT data, troubleshoot issues, optimize their automation setups, and discover insights about their connected devices and systems.`,
})
}
private getApiKeyFromStorage(): string | undefined {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage.getItem('llm_api_key') || undefined
}
return undefined
}
private getApiKeyFromEnv(): string | undefined {
if (typeof process !== 'undefined' && process.env) {
// Try provider-specific env vars first, then fall back to generic
if (this.provider === 'gemini') {
return process.env.GEMINI_API_KEY || process.env.LLM_API_KEY
} else {
return process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
}
}
return undefined
}
private getProviderFromStorage(): LLMProvider | undefined {
if (typeof window !== 'undefined' && window.localStorage) {
const provider = window.localStorage.getItem('llm_provider')
return provider === 'gemini' || provider === 'openai' ? provider : undefined
}
return undefined
}
private getProviderFromEnv(): LLMProvider | undefined {
if (typeof process !== 'undefined' && process.env) {
const provider = process.env.LLM_PROVIDER
return provider === 'gemini' || provider === 'openai' ? provider : undefined
}
return undefined
}
private getNeighboringTopicsTokenLimitFromEnv(): number | undefined {
if (typeof process !== 'undefined' && process.env) {
const limit = parseInt(process.env.LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT || '', 10)
return isNaN(limit) ? undefined : limit
}
return undefined
}
/**
* Save API key to local storage
*/
public saveApiKey(apiKey: string): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem('llm_api_key', apiKey)
}
}
/**
* Save provider to local storage
*/
public saveProvider(provider: LLMProvider): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem('llm_provider', provider)
}
}
/**
* Clear API key from local storage
*/
public clearApiKey(): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem('llm_api_key')
}
}
/**
* Check if API key is configured
*/
public hasApiKey(): boolean {
return !!this.getApiKeyFromStorage()
}
/**
* Get current provider
*/
public getProvider(): LLMProvider {
return this.provider
}
/**
* Estimate tokens in text (rough approximation: ~4 characters per token)
*/
private estimateTokens(text: string): number {
// Simple estimation: average ~4 characters per token
// This is a rough approximation for both OpenAI and Gemini
return Math.ceil(text.length / 4)
}
/**
* Truncate text to fit within token limit
* Returns object with truncated text and flag indicating if truncation occurred
*/
private truncateToTokenLimit(text: string, tokenLimit: number): { text: string; truncated: boolean } {
const estimatedTokens = this.estimateTokens(text)
if (estimatedTokens <= tokenLimit) {
return { text, truncated: false }
}
// Truncate to approximate character count
const maxChars = tokenLimit * 4
if (text.length <= maxChars) {
return { text, truncated: false }
}
return {
text: text.substring(0, maxChars - 3) + '...',
truncated: true
}
}
/**
* Escape string for single-line representation (no newlines)
* Encodes newlines and other special characters similar to JSON string encoding
*/
private escapeToSingleLine(text: string): string {
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/\n/g, '\\n') // Encode newlines
.replace(/\r/g, '\\r') // Encode carriage returns
.replace(/\t/g, '\\t') // Encode tabs
.replace(/"/g, '\\"') // Escape quotes
}
/**
* Format value for LLM context (machine-friendly, single-line)
*/
private formatValueForContext(value: any, tokenLimit: number, markTruncation: boolean = true): string {
let valueStr: string
if (typeof value === 'object' && value !== null) {
// For objects, use JSON.stringify which handles escaping
valueStr = JSON.stringify(value)
} else {
valueStr = String(value)
}
// Escape to single line
const escaped = this.escapeToSingleLine(valueStr)
// Truncate if needed
const result = this.truncateToTokenLimit(escaped, tokenLimit)
if (result.truncated && markTruncation) {
return `[TRUNCATED] ${result.text}`
}
return result.text
}
/**
* Generate context from topic data including neighboring topics
*/
public generateTopicContext(topic: {
path?: () => string
message?: any
messages?: number
childTopicCount?: () => number
type?: string
parent?: any
edgeCollection?: any
}): string {
const context = []
if (topic.path) {
context.push(`Topic: ${topic.path()}`)
}
// Add current value with preview (allow more tokens for main topic - 200 tokens)
if (topic.message?.payload) {
const [value] = topic.message.payload.format(topic.type)
if (value !== null && value !== undefined) {
// Main topic value can contain newlines, format for LLM
const formattedValue = this.formatValueForContext(value, 200, true)
context.push(`Value: ${formattedValue}`)
}
// Add retained status if true
if (topic.message.retain) {
context.push(`Retained: true`)
}
}
// Add neighboring topics (siblings and children) up to token limit
// Full topic paths with single-line previews
const neighbors: string[] = []
let neighborsTokenCount = 0
const tokenLimit = this.neighboringTopicsTokenLimit
// Helper function to add a neighbor if within token limit
const addNeighbor = (fullPath: string, value: any): boolean => {
// Format value as single-line preview (no newlines)
const preview = this.formatValueForContext(value, 20, false) // 20 tokens per neighbor
const neighborEntry = ` ${fullPath}: ${preview}`
const tokens = this.estimateTokens(neighborEntry)
if (neighborsTokenCount + tokens <= tokenLimit) {
neighbors.push(neighborEntry)
neighborsTokenCount += tokens
return true
}
return false
}
// Get parent path for constructing full paths
const parentPath = topic.parent?.path ? topic.parent.path() : ''
// Get siblings from parent
if (topic.parent && topic.parent.edgeCollection) {
const siblings = topic.parent.edgeCollection.edges || []
for (const edge of siblings) {
if (neighborsTokenCount >= tokenLimit) break
if (edge.name && edge.node && edge.node.message?.payload) {
const [siblingValue] = edge.node.message.payload.format(edge.node.type)
if (siblingValue !== null && siblingValue !== undefined) {
const fullPath = parentPath ? `${parentPath}/${edge.name}` : edge.name
if (!addNeighbor(fullPath, siblingValue)) {
break
}
}
}
}
}
// Get children
const currentPath = topic.path ? topic.path() : ''
if (topic.edgeCollection?.edges && neighborsTokenCount < tokenLimit) {
const children = topic.edgeCollection.edges || []
for (const edge of children) {
if (neighborsTokenCount >= tokenLimit) break
if (edge.name && edge.node && edge.node.message?.payload) {
const [childValue] = edge.node.message.payload.format(edge.node.type)
if (childValue !== null && childValue !== undefined) {
const fullPath = currentPath ? `${currentPath}/${edge.name}` : edge.name
if (!addNeighbor(fullPath, childValue)) {
break
}
}
}
}
}
if (neighbors.length > 0) {
context.push(`\nRelated Topics (${neighbors.length}):`)
context.push(neighbors.join('\n'))
}
// Add metadata
if (topic.messages) {
context.push(`\nMessages: ${topic.messages}`)
}
if (topic.childTopicCount) {
const childCount = topic.childTopicCount()
if (childCount > 0) {
context.push(`Subtopics: ${childCount}`)
}
}
return context.join('\n')
}
/**
* Send a message to the LLM and get a response
*/
public async sendMessage(userMessage: string, topicContext?: string): Promise<string> {
try {
// Add topic context if provided
let messageContent = userMessage
if (topicContext) {
messageContent = `Context:\n${topicContext}\n\nUser Question: ${userMessage}`
}
// Add user message to history
this.conversationHistory.push({
role: 'user',
content: messageContent,
})
let assistantMessage: string
if (this.provider === 'gemini') {
// Gemini API format
const apiKey = this.getApiKeyFromStorage()
const contents = this.conversationHistory
.filter(msg => msg.role !== 'system')
.map(msg => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}))
// Prepend system message as first user message for Gemini
const systemMsg = this.conversationHistory.find(msg => msg.role === 'system')
if (systemMsg && contents.length > 0) {
contents[0].parts.unshift({ text: systemMsg.content })
}
const response = await this.axiosInstance.post(
`/models/${this.model}:generateContent?key=${apiKey}`,
{
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 500,
},
}
)
if (!response.data.candidates || response.data.candidates.length === 0) {
throw new Error('No response from AI assistant')
}
assistantMessage = response.data.candidates[0].content.parts[0].text
} else {
// OpenAI API format
const response = await this.axiosInstance.post('/chat/completions', {
model: this.model,
messages: this.conversationHistory,
temperature: 0.7,
max_tokens: 500,
})
if (!response.data.choices || response.data.choices.length === 0) {
throw new Error('No response from AI assistant')
}
assistantMessage = response.data.choices[0].message.content
}
// Add assistant response to history
this.conversationHistory.push({
role: 'assistant',
content: assistantMessage,
})
// Keep conversation history manageable (last 10 messages + system)
if (this.conversationHistory.length > 11) {
this.conversationHistory = [
this.conversationHistory[0], // Keep system message
...this.conversationHistory.slice(-10), // Keep last 10 messages
]
}
return assistantMessage
} catch (error: unknown) {
console.error('LLM Service Error:', error)
const err = error as { response?: { status?: number; data?: any }; code?: string; message?: string }
if (err.response?.status === 401 || err.response?.status === 403) {
throw new Error('Invalid API key. Please check your configuration.')
} else if (err.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.')
} else if (err.code === 'ECONNABORTED') {
throw new Error('Request timeout. Please try again.')
} else {
throw new Error(err.message || 'Failed to get response from AI assistant.')
}
}
}
/**
* Clear conversation history
*/
public clearHistory(): void {
this.conversationHistory = [this.conversationHistory[0]] // Keep only system message
}
/**
* Get quick suggestions based on topic
*/
public getQuickSuggestions(topic: { message?: { payload?: any }; childTopicCount?: () => number; messages?: number }): string[] {
const suggestions = []
if (topic.message?.payload) {
suggestions.push('Explain this data structure')
suggestions.push('What does this value mean?')
}
if (topic.childTopicCount && topic.childTopicCount() > 0) {
suggestions.push('Summarize all subtopics')
}
if (topic.messages > 1) {
suggestions.push('Analyze message patterns')
}
suggestions.push('What can I do with this topic?')
return suggestions
}
}
// Export a singleton instance
let llmServiceInstance: LLMService | null = null
export function getLLMService(): LLMService {
if (!llmServiceInstance) {
llmServiceInstance = new LLMService()
}
return llmServiceInstance
}
export function resetLLMService(): void {
llmServiceInstance = null
}
-8
View File
@@ -1,8 +0,0 @@
// Export store singleton for use in other modules
import reducers from './reducers'
import { thunk as reduxThunk } from 'redux-thunk'
import { applyMiddleware, compose, createStore } from 'redux'
import { batchDispatchMiddleware } from 'redux-batched-actions'
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
export const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
+1 -1
View File
@@ -1,4 +1,4 @@
import { rendererRpc } from '../eventBus'
import { rendererRpc } from '../../../events'
import { storageStoreEvent, storageLoadEvent, storageClearEvent } from '../../../events/StorageEvents'
-166
View File
@@ -1,166 +0,0 @@
/**
* Generic test utilities for React component testing
*
* This file provides a reusable testing setup that can be used across all component tests.
* It includes:
* - Custom render function with theme and Redux providers
* - Common test utilities and matchers
* - Mock setup helpers
*/
import React from 'react'
import { render, RenderOptions, RenderResult } from '@testing-library/react'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
// Setup JSDOM environment for tests
import 'jsdom-global/register'
// Setup global mocks
mockResizeObserver()
/**
* Helper to create a ResizeObserver mock
* This is set up globally for all tests
*/
export function mockResizeObserver() {
const MockResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any
if (typeof global.ResizeObserver === 'undefined') {
global.ResizeObserver = MockResizeObserver
}
if (typeof window !== 'undefined' && typeof window.ResizeObserver === 'undefined') {
(window as any).ResizeObserver = MockResizeObserver
}
}
/**
* Default theme for testing
*/
const defaultTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
light: '#42a5f5',
dark: '#1565c0',
},
secondary: {
main: '#9c27b0',
light: '#ba68c8',
dark: '#7b1fa2',
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.6)',
},
divider: 'rgba(0, 0, 0, 0.12)',
},
})
/**
* Default Redux store for testing
*/
const createTestStore = (initialState = {}) => {
return configureStore({
reducer: {
// Add minimal reducers as needed
charts: (state = { charts: [] }) => state,
connection: (state = {}) => state,
},
preloadedState: initialState,
})
}
/**
* Custom render options
*/
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
theme?: typeof defaultTheme
store?: ReturnType<typeof createTestStore>
withTheme?: boolean
withRedux?: boolean
}
/**
* Custom render function that wraps components with necessary providers
*
* @example
* ```tsx
* import { renderWithProviders } from '../utils/spec/testUtils'
*
* describe('MyComponent', () => {
* it('renders correctly', () => {
* const { getByText } = renderWithProviders(<MyComponent />)
* expect(getByText('Hello')).toBeDefined()
* })
* })
* ```
*/
export function renderWithProviders(
ui: React.ReactElement,
{
theme = defaultTheme,
store = createTestStore(),
withTheme = true,
withRedux = false,
...renderOptions
}: CustomRenderOptions = {}
): RenderResult {
let Wrapper: React.FC<{ children: React.ReactNode }>
if (withRedux && withTheme) {
Wrapper = ({ children }) => (
<Provider store={store}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</Provider>
)
} else if (withRedux) {
Wrapper = ({ children }) => (
<Provider store={store}>
{children}
</Provider>
)
} else if (withTheme) {
Wrapper = ({ children }) => (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
)
} else {
Wrapper = ({ children }) => <>{children}</>
}
return render(ui, { wrapper: Wrapper, ...renderOptions })
}
/**
* Helper to create mock chart data
*/
export function createMockChartData(count: number = 10): Array<{ x: number; y: number }> {
const now = Date.now()
return Array.from({ length: count }, (_, i) => ({
x: now - (count - i) * 1000,
y: Math.random() * 100,
}))
}
/**
* Helper to wait for async operations
*/
export const waitFor = async (ms: number = 100) => {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Re-export everything from @testing-library/react for convenience
*/
export * from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
+1 -1
View File
@@ -10,7 +10,7 @@
"sourceMap": true,
"module": "esnext",
"target": "ES2020",
"jsx": "react-jsx",
"jsx": "react",
"paths": {
"react": ["./node_modules/@types/react"]
},
+4
View File
@@ -54,6 +54,10 @@ export default {
// Point to browser event bus when importing from '../../../../events'
resource.request = path.resolve(__dirname, 'src', 'browserEventBus.ts')
}),
// Replace EventSystem/EventBus directly as well
new webpack.NormalModuleReplacementPlugin(/events[\\/]EventSystem[\\/]EventBus$/, resource => {
resource.request = path.resolve(__dirname, 'src', 'browserEventBus.ts')
}),
// Exclude IPC-based EventBus files completely
new webpack.IgnorePlugin({
resourceRegExp: /IpcRendererEventBus\.ts$/,
+445 -894
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@
"node": ">=20"
},
"author": "",
"license": "CC-BY-SA-4.0",
"license": "CC-BY-ND-4.0",
"dependencies": {
"@types/sha1": "^1.1.5",
"builder-util-runtime": "^9.3.1",
+959 -645
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
version: '3.8'
services:
mqtt-explorer:
image: ghcr.io/thomasnordquist/mqtt-explorer:latest
ports:
- "3000:3000"
environment:
- MQTT_EXPLORER_USERNAME=admin
- MQTT_EXPLORER_PASSWORD=changeme
# Uncomment to disable authentication (use only behind a secure proxy!)
# - MQTT_EXPLORER_SKIP_AUTH=true
volumes:
- mqtt-explorer-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
mqtt-explorer-data:
-59
View File
@@ -1,59 +0,0 @@
/**
* Browser-compatible dialog type definitions
* These types mirror Electron's dialog types but don't require the electron package
*/
export interface FileFilter {
name: string
extensions: string[]
}
export interface OpenDialogOptions {
title?: string
defaultPath?: string
buttonLabel?: string
filters?: FileFilter[]
properties?: Array<
| 'openFile'
| 'openDirectory'
| 'multiSelections'
| 'showHiddenFiles'
| 'createDirectory'
| 'promptToCreate'
| 'noResolveAliases'
| 'treatPackageAsDirectory'
| 'dontAddToRecent'
>
message?: string
securityScopedBookmarks?: boolean
}
export interface OpenDialogReturnValue {
canceled: boolean
filePaths: string[]
bookmarks?: string[]
}
export interface SaveDialogOptions {
title?: string
defaultPath?: string
buttonLabel?: string
filters?: FileFilter[]
message?: string
nameFieldLabel?: string
showsTagField?: boolean
properties?: Array<
| 'showHiddenFiles'
| 'createDirectory'
| 'treatPackageAsDirectory'
| 'showOverwriteConfirmation'
| 'dontAddToRecent'
>
securityScopedBookmarks?: boolean
}
export interface SaveDialogReturnValue {
canceled: boolean
filePath?: string
bookmark?: string
}
+2 -2
View File
@@ -62,8 +62,8 @@ export interface CertificateUploadResponse {
data: string // base64 encoded
}
// Dialog types (browser-compatible versions)
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from './DialogTypes'
// Electron dialog types (re-exported for convenience)
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
export type OpenDialogOptionsV2 = OpenDialogOptions
export type OpenDialogReturnValueV2 = OpenDialogReturnValue
+1 -1
View File
@@ -1,4 +1,4 @@
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from './DialogTypes'
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { RpcEvent } from './EventSystem/Rpc'
// Legacy functions - use RpcEvents from EventsV2.ts for new code
+1 -3
View File
@@ -1,7 +1,5 @@
export * from './Events'
export * from './EventsV2'
export * from './EventSystem/EventDispatcher'
// EventBus exports removed - this file contains Electron-specific imports
// In Electron mode, webpack replaces '../../../events' to use './EventSystem/EventBus'
// In browser mode, webpack replaces '../../../events' to use browserEventBus.ts
export * from './EventSystem/EventBus'
export * from './EventSystem/EventBusInterface'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Some files were not shown because too many files have changed in this diff Show More