Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andthomasnordquist 0dabd89ef2 Address code review feedback - add clarifying comments
- Add comment explaining TEST_STATUS outcome values
- Improve error message specificity in generateMarkdownSummary.js

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-31 20:39:12 +00:00
copilot-swe-agent[bot]andthomasnordquist e338ab9ccf Update demo-video workflow to upload videos even on test failures
- Add continue-on-error: true to video generation step
- Add if: always() to post-processing and upload steps
- Add file existence checks before uploading
- Update generateMarkdownSummary.js to support test status parameter
- Display warning when test fails but videos are uploaded

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-31 20:36:55 +00:00
copilot-swe-agent[bot] 5823769b8a Initial plan 2025-12-31 20:31:21 +00:00
48 changed files with 1418 additions and 6116 deletions
-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"
}
}
]
}
+1 -108
View File
@@ -20,117 +20,10 @@
- `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)
**Important:** Browser UI tests require MQTT broker. In CI, GitHub Actions health checks ensure the mosquitto service is ready before tests run.
## Browser Mode
+32 -16
View File
@@ -20,8 +20,6 @@ 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
@@ -68,8 +66,12 @@ jobs:
- name: Build
run: yarn build
- name: Generate Demo Video
id: generate_video
continue-on-error: true
run: yarn ui-test
- name: Post-processing
if: always()
continue-on-error: true
run: ./scripts/prepareVideo.sh
- name: Generate unique base path
id: basepath
@@ -91,24 +93,32 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: 'eu-central-1'
- name: Upload full video to S3
if: always()
continue-on-error: true
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
# Upload GIF if it exists
if [ -f ./ui-test.gif ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test.gif \
--body ./ui-test.gif \
--content-type image/gif
fi
# Upload MP4 if it exists
if [ -f ./ui-test.mp4 ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test.mp4 \
--body ./ui-test.mp4 \
--content-type video/mp4
fi
- name: Upload video segments to S3
if: always()
continue-on-error: true
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
@@ -126,6 +136,7 @@ jobs:
done
shopt -u nullglob # Restore default behavior
- name: Generate file URLs
if: always()
id: fileurl
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
@@ -135,20 +146,25 @@ jobs:
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 }}
# Outcome can be: 'success', 'failure', 'cancelled', or 'skipped'
TEST_STATUS: ${{ steps.generate_video.outcome }}
run: |
MARKDOWN=$(node ./scripts/generateMarkdownSummary.js "${BASE_URL}")
MARKDOWN=$(node ./scripts/generateMarkdownSummary.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 video to PR
if: always()
uses: actions/github-script@v7
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
-2
View File
@@ -68,8 +68,6 @@ docker-compose up -d
| `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
-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
-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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+2 -2
View File
@@ -33,7 +33,7 @@
"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",
@@ -91,7 +91,7 @@
"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",
-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,
})
-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)
-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'),
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ const styles = (theme: Theme) => ({
height: '100%',
padding: '8px',
flex: 1,
overflow: 'auto',
overflow: 'hidden scroll',
},
})
@@ -36,8 +36,6 @@ interface Props {
classes: any
connections: Array<{ id: string; name?: string; host?: string }>
currentConnectionId?: string
isConnected: boolean
currentActiveConnectionId?: string
actions: typeof connectionManagerActions
}
@@ -56,7 +54,7 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
}
public render() {
const { classes, connections, currentConnectionId, isConnected, currentActiveConnectionId } = this.props
const { classes, connections, currentConnectionId } = this.props
if (!connections || connections.length === 0) {
return null
@@ -79,19 +77,12 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
}}
>
{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 isConnected = conn.id === currentConnectionId
const displayName = this.getConnectionDisplayName(conn)
return (
<MenuItem key={conn.id} value={conn.id}>
{displayName}
{showConnectedStatus && ' (Connected)'}
{isConnected && ' (Connected)'}
</MenuItem>
)
})}
@@ -111,20 +102,17 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
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,
}))
: []
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,
}
}
@@ -134,7 +122,4 @@ const mapDispatchToProps = (dispatch: any) => {
}
}
// 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)
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(MobileConnectionSelector))
+17 -21
View File
@@ -9,9 +9,6 @@ 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,14 +18,13 @@ 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 [mobileTab, setMobileTab] = React.useState(0) // 0 = topics, 1 = details
const [height, setHeight] = React.useState<string | number>('100%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [detectedHeight, setDetectedHeight] = React.useState(0)
@@ -92,6 +88,19 @@ function ContentView(props: Props) {
// Mobile view with tab switcher
if (isMobile) {
// Expose tab switching functions for other components to call
React.useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).switchToDetailsTab = () => setMobileTab(1)
(window as any).switchToTopicsTab = () => setMobileTab(0)
}
return () => {
if (typeof window !== 'undefined') {
delete (window as any).switchToDetailsTab
delete (window as any).switchToTopicsTab
}
}
}, [])
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
@@ -134,32 +143,20 @@ function ContentView(props: Props) {
return (
<div style={mobileContainerStyle}>
<MobileTabs value={props.mobileTab} onChange={(tab) => props.dispatch(setMobileTab(tab))} />
<MobileTabs value={mobileTab} onChange={setMobileTab} />
<div style={tabContentStyle}>
{/* Topics tab */}
{props.mobileTab === 0 && (
{mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{props.mobileTab === 1 && (
{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>
)
@@ -226,7 +223,6 @@ function ContentView(props: Props) {
const mapStateToProps = (state: AppState) => {
return {
chartPanelItems: state.charts.get('charts'),
mobileTab: state.globalState.get('mobileTab'),
}
}
+1 -23
View File
@@ -2,10 +2,6 @@ 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
@@ -26,10 +22,9 @@ function MobileTabs(props: Props) {
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
aria-label="Topics, Details, Publish and Charts tabs"
aria-label="Topics and Details tabs"
>
<Tab
icon={<AccountTreeIcon />}
label="Topics"
data-testid="mobile-tab-topics"
aria-label="View topics tree"
@@ -37,29 +32,12 @@ function MobileTabs(props: Props) {
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>
)
+5 -5
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,7 +17,6 @@ function SearchBar(props: {
hasConnection: boolean
actions: {
settings: typeof settingsActions
global: typeof globalActions
}
}) {
const { actions, classes, hasConnection, topicFilter } = props
@@ -28,9 +27,11 @@ function SearchBar(props: {
setHasFocus(true)
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
actions.global.setMobileTab(0)
if ((window as any).switchToTopicsTab) {
(window as any).switchToTopicsTab()
}
}
}, [actions])
}, [])
const onBlur = useCallback(() => setHasFocus(false), [])
const clearFilter = useCallback(() => {
@@ -100,7 +101,6 @@ const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, 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)
-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))
@@ -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',
},
})
+4 -2
View File
@@ -69,14 +69,16 @@ function TreeNodeComponent(props: Props) {
// Expanding is handled by the separate expand button click
didSelectTopic()
// Switch to details tab on mobile after selecting a topic
actions.setMobileTab(1)
if (typeof window !== 'undefined' && (window as any).switchToDetailsTab) {
(window as any).switchToDetailsTab()
}
} else {
// Desktop: Original behavior - select AND toggle (click anywhere works)
didSelectTopic()
setCollapsedOverride(!isCollapsed)
}
},
[isCollapsed, didSelectTopic, actions]
[isCollapsed, didSelectTopic]
)
const toggleCollapsed = useCallback(
+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>
)
}
-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
}
-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
}
+11 -11
View File
@@ -2623,10 +2623,10 @@ diff@^5.2.0:
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
diff@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.3.tgz#c7da3d9e0e8c283bb548681f8d7174653720c2d5"
integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==
diff@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a"
integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==
dns-packet@^5.2.2:
version "5.6.1"
@@ -3760,10 +3760,10 @@ lodash.throttle@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.23:
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@^4.1.0:
version "4.1.0"
@@ -4346,9 +4346,9 @@ punycode@^2.1.0, punycode@^2.3.1:
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qs@^6.12.3, qs@~6.14.0:
version "6.14.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"
integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
version "6.14.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
dependencies:
side-channel "^1.1.0"
+959 -645
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

+8 -14
View File
@@ -29,12 +29,12 @@
"dev:server": "npm-run-all --parallel dev:server:*",
"dev:server:app": "(cd app && npx webpack-dev-server --config webpack.browser.config.mjs --mode development --progress)",
"dev:server:backend": "tsc && node dist/src/server.js",
"lint": "npm-run-all --parallel lint:prettier lint:eslint lint:spellcheck",
"lint:fix": "npm-run-all lint:eslint:fix lint:prettier:fix",
"lint": "npm-run-all --parallel lint:prettier lint:tslint lint:spellcheck",
"lint:fix": "npm-run-all lint:tslint:fix lint:prettier:fix",
"lint:prettier": "prettier --check \"**/*.ts{x,}\"",
"lint:prettier:fix": "prettier --write \"**/*.ts{x,}\"",
"lint:eslint": "eslint --ext .ts,.tsx .",
"lint:eslint:fix": "eslint --ext .ts,.tsx . --fix",
"lint:tslint": "tslint -p ./",
"lint:tslint:fix": "tslint -p ./ --fix",
"lint:spellcheck": "cspell -e ./build -e \"node_modules\" \"**/*.ts{x,}\"",
"build": "tsc && (cd app && yarn run build)",
"build:server": "npx tsc && (cd app && npx webpack --config webpack.browser.config.mjs --mode production)",
@@ -116,21 +116,11 @@
"@types/sha1": "^1.1.1",
"@types/socket.io": "^3.0.2",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"builder-util-runtime": "^9.3.1",
"chai": "^4.5.0",
"cspell": "^8.19.4",
"electron": "39.2.7",
"electron-builder": "^26.4.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"mocha": "^10.8.2",
"mustache": "^4.2.0",
"npm-run-all": "^4.1.5",
@@ -142,6 +132,10 @@
"semantic-release-export-data": "^1.2.0",
"source-map-support": "^0.5.9",
"sparkplug-client": "^3.2.4",
"tslint": "^6.1.3",
"tslint-config-airbnb": "^5.11.2",
"tslint-react": "^5.0.0",
"tslint-react-recommended": "^1.0.15",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
+42 -20
View File
@@ -1,45 +1,67 @@
#!/usr/bin/env node
const fs = require('fs');
// Read scenes.json
const scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
// Get base URL from command line arguments
// Get base URL and test status from command line arguments
const baseUrl = process.argv[2];
const testStatus = process.argv[3] || 'success'; // Default to success if not provided
if (!baseUrl) {
console.error('Usage: node generateMarkdownSummary.js <base-url>');
console.error('Usage: node generateMarkdownSummary.js <base-url> [test-status]');
process.exit(1);
}
// Read scenes.json if it exists
let scenes = [];
try {
if (fs.existsSync('scenes.json')) {
scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
}
} catch (error) {
console.error('Warning: Could not read scenes.json - video segments will not be available:', error.message);
}
// Sanitize scene name to prevent path traversal
function sanitizeName(name) {
// Remove any characters that aren't alphanumeric, dash, or underscore
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
}
// Generate markdown
let markdown = '## 🎬 Demo Video Generated\n\n';
// Generate markdown with status indication
const statusIcon = testStatus === 'success' ? '✅' : '⚠️';
const statusText = testStatus === 'success' ? 'Generated Successfully' : 'Generated (Test Failed)';
let markdown = `## ${statusIcon} Demo Video ${statusText}\n\n`;
if (testStatus !== 'success') {
markdown += `> ⚠️ **Note**: The demo test encountered errors but videos were still uploaded for debugging. Check the logs for details.\n\n`;
}
markdown += `### Full Video\n\n`;
markdown += `[📥 Download Full Video (MP4)](${baseUrl}/ui-test.mp4) | [GIF](${baseUrl}/ui-test.gif)\n\n`;
markdown += `---\n\n`;
markdown += `### 📑 Video Segments\n\n`;
markdown += `<details>\n`;
markdown += `<summary>Click to expand segments</summary>\n\n`;
scenes.forEach((scene, index) => {
const safeName = sanitizeName(scene.name);
const segmentFile = `segment-${String(index + 1).padStart(2, '0')}-${safeName}.gif`;
const title = scene.title || scene.name;
const duration = (scene.duration / 1000).toFixed(1);
if (scenes.length > 0) {
markdown += `### 📑 Video Segments\n\n`;
markdown += `<details>\n`;
markdown += `<summary><strong>${index + 1}. ${title}</strong> (${duration}s)</summary>\n\n`;
markdown += `![${title}](${baseUrl}/${segmentFile})\n\n`;
markdown += `<summary>Click to expand segments</summary>\n\n`;
scenes.forEach((scene, index) => {
const safeName = sanitizeName(scene.name);
const segmentFile = `segment-${String(index + 1).padStart(2, '0')}-${safeName}.gif`;
const title = scene.title || scene.name;
const duration = (scene.duration / 1000).toFixed(1);
markdown += `<details>\n`;
markdown += `<summary><strong>${index + 1}. ${title}</strong> (${duration}s)</summary>\n\n`;
markdown += `![${title}](${baseUrl}/${segmentFile})\n\n`;
markdown += `</details>\n\n`;
});
markdown += `</details>\n\n`;
});
} else {
markdown += `*Scene information not available - check if video processing completed*\n\n`;
}
markdown += `</details>\n\n`;
markdown += `_Videos will expire in 90 days._`;
console.log(markdown);
+7 -36
View File
@@ -20,10 +20,6 @@ const CREDENTIALS_PATH = path.join(process.cwd(), 'data', 'credentials.json')
const MAX_FILE_SIZE = 16 * 1024 * 1024 // 16MB limit for file uploads
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : ['*']
const isProduction = process.env.NODE_ENV === 'production'
// Enable upgrade-insecure-requests only when behind HTTPS reverse proxy
const enableUpgradeInsecure = process.env.UPGRADE_INSECURE_REQUESTS === 'true'
// Enable X-Frame-Options header to prevent iframe embedding (disabled by default)
const enableXFrameOptions = process.env.X_FRAME_OPTIONS === 'true'
/**
* Validates and sanitizes file paths to prevent path traversal attacks
@@ -78,34 +74,16 @@ async function startServer() {
const app = express()
// Apply security headers with helmet
// Get Helmet's default CSP directives and remove upgrade-insecure-requests
// This ensures the directive is never added, even in edge cases
// Create a copy to avoid mutating Helmet's defaults
const defaultCspDirectives = { ...helmet.contentSecurityPolicy.getDefaultDirectives() }
delete defaultCspDirectives['upgrade-insecure-requests']
// Build custom CSP directives, overriding defaults as needed
const cspDirectives = {
...defaultCspDirectives,
// Override default-src from defaults
'default-src': ["'self'"],
// Override script-src for webpack
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // unsafe-eval required for webpack runtime
// Override style-src for Material-UI
'style-src': ["'self'", "'unsafe-inline'"], // Required for Material-UI
// Add WebSocket support
'connect-src': ["'self'", 'ws:', 'wss:'], // Allow WebSocket connections
// Allow data URIs for images
'img-src': ["'self'", 'data:', 'blob:'],
// Only add upgrade-insecure-requests if explicitly enabled via env var
...(enableUpgradeInsecure && { 'upgrade-insecure-requests': [] }),
}
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: false, // Don't merge with Helmet's defaults to ensure full control
directives: cspDirectives,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // unsafe-eval required for webpack runtime
styleSrc: ["'self'", "'unsafe-inline'"], // Required for Material-UI
connectSrc: ["'self'", 'ws:', 'wss:'], // Allow WebSocket connections
imgSrc: ["'self'", 'data:', 'blob:'],
},
},
hsts: isProduction
? {
@@ -114,13 +92,6 @@ async function startServer() {
preload: true,
}
: false,
frameguard: enableXFrameOptions ? { action: 'sameorigin' } : false, // Disabled by default to allow iframe embedding
// Disable cross-origin policies that cause blank pages when accessing via IP vs localhost
// These headers can block resources and cause rendering issues on HTTP-only deployments
crossOriginEmbedderPolicy: false, // Can block resources without proper CORP headers
crossOriginOpenerPolicy: false, // Can cause blank pages and window isolation issues
crossOriginResourcePolicy: false, // Can block cross-origin resource loading
originAgentCluster: false, // Causes issues when switching between localhost and IP address origins
})
)
-84
View File
@@ -1,84 +0,0 @@
import { chromium, Page } from 'playwright'
async function inspect() {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
console.log('Navigating to localhost:3000...')
await page.goto('http://localhost:3000')
await page.waitForTimeout(2000)
// Login
console.log('Logging in...')
await page.fill('[name="username"]', 'test')
await page.fill('[name="password"]', 'test123')
await page.locator('[type="submit"]').click()
await page.waitForTimeout(3000)
// Expand kitchen/coffee_maker topic
console.log('Expanding kitchen topic...')
const kitchenTopic = page.locator('[data-test-topic="kitchen"]').first()
await kitchenTopic.click()
await page.waitForTimeout(500)
console.log('Clicking coffee_maker topic...')
const coffeeMakerTopic = page.locator('[data-test-topic="kitchen/coffee_maker"]').first()
await coffeeMakerTopic.click()
await page.waitForTimeout(1500)
// Look for ShowChart icons
console.log('\n=== ShowChart Elements ===')
const showCharts = await page.locator('//*[contains(@data-test-type, "ShowChart")]').all()
console.log(`Found ${showCharts.length} ShowChart elements:`)
for (let i = 0; i < showCharts.length; i++) {
const dataTest = await showCharts[i].getAttribute('data-test')
const isVisible = await showCharts[i].isVisible()
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}`)
}
// Click heater ShowChart icon
console.log('\n=== Clicking heater ShowChart ===')
const heaterChart = page.locator('//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "heater")]').first()
await heaterChart.waitFor({ state: 'visible', timeout: 5000 })
await heaterChart.click()
await page.waitForTimeout(2000)
// Check for ChartPanel
console.log('\n=== Looking for ChartPanel ===')
const chartPanels = await page.locator('[data-test-type="ChartPaper"]').all()
console.log(`Found ${chartPanels.length} ChartPanel elements`)
for (let i = 0; i < chartPanels.length; i++) {
const dataTest = await chartPanels[i].getAttribute('data-test')
console.log(` [${i}] data-test="${dataTest}"`)
}
// Look for ChartSettings buttons
console.log('\n=== Looking for ChartSettings Elements ===')
const allSettings = await page.locator('//*[@data-test-type="ChartSettings"]').all()
console.log(`Found ${allSettings.length} ChartSettings elements (using @data-test-type):`)
for (let i = 0; i < allSettings.length; i++) {
const dataTest = await allSettings[i].getAttribute('data-test')
const isVisible = await allSettings[i].isVisible()
const box = await allSettings[i].boundingBox()
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}, box=${JSON.stringify(box)}`)
}
// Try different locator strategies
console.log('\n=== Trying contains query for ChartSettings with "heater" ===')
const heaterSettings = page.locator('//*[contains(@data-test-type, "ChartSettings")][contains(@data-test, "heater")]')
const count = await heaterSettings.count()
console.log(`Found ${count} elements`)
if (count > 0) {
const dataTest = await heaterSettings.first().getAttribute('data-test')
const isVisible = await heaterSettings.first().isVisible()
console.log(` data-test="${dataTest}", visible=${isVisible}`)
}
// Take screenshot
await page.screenshot({ path: '/tmp/chart-settings-inspection.png', fullPage: true })
console.log('\nScreenshot saved to /tmp/chart-settings-inspection.png')
await browser.close()
}
inspect().catch(console.error)
+2 -4
View File
@@ -2,9 +2,7 @@ import { Page } from 'playwright'
import { clickOn } from '../util'
export async function copyTopicToClipboard(browser: Page) {
// Select the first copy button (topic path copy button in the new sidebar structure)
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
const copyButtons = browser.getByTestId('copy-button')
const copyButton = copyButtons.first()
// Select the copy button specifically in the Topic panel (not Value panel or MessageHistory)
const copyButton = browser.getByRole('button', { name: /Topic/i }).getByTestId('copy-button')
await clickOn(copyButton, 1)
}
+2 -4
View File
@@ -2,9 +2,7 @@ import { Page } from 'playwright'
import { clickOn } from '../util'
export async function copyValueToClipboard(browser: Page) {
// Select the second copy button (value copy button in the new sidebar structure)
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
const copyButtons = browser.getByTestId('copy-button')
const copyButton = copyButtons.nth(1) // Second copy button is for the value
// Select the copy button specifically in the Value panel (not Topic panel or MessageHistory)
const copyButton = browser.getByRole('button', { name: /Value/i }).getByTestId('copy-button')
await clickOn(copyButton, 1)
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { Page } from 'playwright'
import { clickOn } from '../util'
export async function saveMessageToFile(browser: Page) {
// Select the save button in the new sidebar structure (directly by testid)
const saveButton = browser.getByTestId('save-button')
// Select the save button specifically in the Value panel
const saveButton = browser.getByRole('button', { name: /Value/i }).getByTestId('save-button')
await clickOn(saveButton, 1)
}
+4 -15
View File
@@ -5,25 +5,20 @@ export async function showNumericPlot(browser: Page) {
// On desktop, expandTopic will also select the topic (original behavior restored)
// This shows the JSON properties in the details panel where chart icons are located
await expandTopic('kitchen/coffee_maker', browser)
// Switch to Details tab to ensure ShowChart icons are visible
await switchToDetailsTab(browser)
await sleep(500)
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
await moveToCenterOfElement(heater)
await sleep(1000)
// Refocus and click (force:true bypasses tooltip overlay)
// Refocus and click
heater = await valuePreviewGuttersShowChartIcon('heater', browser)
await heater.click({ force: true })
await heater.click()
await sleep(1000)
let temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
await moveToCenterOfElement(temperature)
await sleep(1000)
// Refocus and click (force:true bypasses tooltip overlay)
// Refocus and click
temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
await temperature.click({ force: true })
await temperature.click()
await sleep(1000)
await chartSettings('heater', browser)
@@ -86,9 +81,3 @@ async function clickOnMenuPoint(name: string, browser: Page) {
const item = await browser.locator(`[data-menu-item="${name}"]`)
return clickOn(item)
}
async function switchToDetailsTab(browser: Page) {
// Click the Details tab to ensure it's active and ShowChart icons are visible
const detailsTab = browser.getByRole('tab', { name: 'Details' })
await detailsTab.click()
}
+7 -11
View File
@@ -275,11 +275,8 @@ describe('MQTT Explorer UI Tests', function () {
await expandTopic('livingroom/lamp/state', page)
await sleep(1000)
// When: Copy topic button is clicked (in the topic section at the top)
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
// We need to find the first copy button (topic path copy button)
const copyButtons = page.getByTestId('copy-button')
const copyTopicButton = copyButtons.first()
// When: Copy topic button is clicked
const copyTopicButton = page.getByRole('button', { name: /Topic/i }).getByTestId('copy-button')
await copyTopicButton.click()
await sleep(500)
@@ -320,9 +317,8 @@ describe('MQTT Explorer UI Tests', function () {
it('should copy message value to clipboard in both Electron and browser modes', async function () {
// Given: A topic with a value is selected (reuse already expanded topic)
// When: Copy value button is clicked (the second copy button in the value section)
const copyButtons = page.getByTestId('copy-button')
const copyValueButton = copyButtons.nth(1) // Second copy button is for the value
// When: Copy value button is clicked
const copyValueButton = page.getByRole('button', { name: /Value/i }).getByTestId('copy-button')
await copyValueButton.click()
await sleep(500)
@@ -371,8 +367,8 @@ describe('MQTT Explorer UI Tests', function () {
// In browser mode, set up download handling
const downloadPromise = page.waitForEvent('download', { timeout: 10000 })
// When: Save button is clicked (in the new sidebar, save button is in the value section)
const saveButton = page.getByTestId('save-button')
// When: Save button is clicked
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
await saveButton.click()
// Then: Download should be triggered
@@ -389,7 +385,7 @@ describe('MQTT Explorer UI Tests', function () {
} else {
// In Electron mode, the file dialog would open
// We can't easily test the native file dialog, but we can verify the button works
const saveButton = page.getByTestId('save-button')
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
const isVisible = await saveButton.isVisible()
expect(isVisible).to.be.true
-291
View File
@@ -1,291 +0,0 @@
import 'mocha'
import { expect } from 'chai'
import { Browser, BrowserContext, Page, chromium } from 'playwright'
import { createTestMock, stopTestMock } from './mock-mqtt-test'
import { sleep } from './util'
import { connectTo } from './scenarios/connect'
import type { MqttClient } from 'mqtt'
/**
* Viewport Switching Test
*
* This test checks for React errors when switching between mobile and desktop viewports.
* The breakpoint is at 768px width.
*/
describe('Viewport Switching Tests', function () {
this.timeout(120000)
let browser: Browser | undefined
let browserContext: BrowserContext | undefined
let testMock: MqttClient
let page: Page
before(async function () {
this.timeout(90000)
console.log('Creating test-specific MQTT mock...')
testMock = await createTestMock()
console.log('Publishing test topics...')
testMock.publish('livingroom/lamp/state', 'on', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/brightness', '128', { retain: true, qos: 0 })
testMock.publish('livingroom/temperature', '21.0', { retain: true, qos: 0 })
const coffeeData = {
heater: 'on',
temperature: 92.5,
waterLevel: 0.5,
}
testMock.publish('kitchen/coffee_maker', JSON.stringify(coffeeData), { retain: true, qos: 2 })
testMock.publish('kitchen/lamp/state', 'off', { retain: true, qos: 0 })
await sleep(2000) // Let MQTT messages propagate
console.log('Launching browser...')
const browserUrl = process.env.BROWSER_MODE_URL || 'http://localhost:3000'
console.log(`Browser URL: ${browserUrl}`)
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
})
// Start with mobile viewport (below 768px)
browserContext = await browser.newContext({
viewport: {
width: 412,
height: 914,
},
permissions: ['clipboard-read', 'clipboard-write'],
})
page = await browserContext.newPage()
// Collect console messages and errors
const consoleMessages: string[] = []
const pageErrors: Error[] = []
page.on('console', msg => {
const text = msg.text()
consoleMessages.push(`[${msg.type()}] ${text}`)
console.log('Browser console:', msg.type(), text)
})
page.on('pageerror', error => {
pageErrors.push(error)
console.error('Browser error:', error.message)
})
// Store these in page context for access in tests
;(page as any).testConsoleMessages = consoleMessages
;(page as any).testPageErrors = pageErrors
// Navigate to the browser mode URL
await page.goto(browserUrl, { timeout: 30000, waitUntil: 'networkidle' })
// Handle authentication if required
const username = process.env.MQTT_EXPLORER_USERNAME || 'test'
const password = process.env.MQTT_EXPLORER_PASSWORD || 'test123'
console.log('Waiting for page to initialize...')
await sleep(5000)
const loginDialog = page.locator('h2:has-text("Login to MQTT Explorer")')
let loginDialogVisible = false
try {
loginDialogVisible = await loginDialog.isVisible({ timeout: 10000 })
} catch (error) {
console.log('Login dialog not found - assuming auth is disabled')
}
if (loginDialogVisible) {
console.log('Login dialog detected, authenticating...')
await page.fill('[data-testid="username-input"] input', username)
await page.fill('[data-testid="password-input"] input', password)
await page.click('button:has-text("Login")')
await sleep(3000)
console.log('Authentication complete')
}
// Wait for the connection dialog to appear
console.log('Waiting for MQTT connection dialog...')
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
console.log('Connecting to MQTT broker...')
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
await connectTo(brokerHost, page)
await sleep(3000) // Give time for topics to load
console.log('Setup complete (mobile viewport)')
})
after(async function () {
this.timeout(10000)
if (browserContext) {
await browserContext.close()
}
if (browser) {
await browser.close()
}
stopTestMock()
})
describe('Mobile to Desktop Viewport Switch', () => {
it('should switch from mobile (412px) to desktop (1280px) without React errors', async function () {
// Given: Mobile viewport (412x914) with topics loaded
console.log('Current viewport: 412x914 (mobile)')
await page.screenshot({ path: 'test-viewport-mobile-before.png', fullPage: true })
// Clear previous errors
const pageErrors = (page as any).testPageErrors as Error[]
const consoleMessages = (page as any).testConsoleMessages as string[]
pageErrors.length = 0
consoleMessages.length = 0
// When: Switch to desktop viewport (>768px)
console.log('Switching viewport to 1280x720 (desktop)...')
await page.setViewportSize({ width: 1280, height: 720 })
await sleep(2000) // Wait for resize handlers and re-renders
console.log('Viewport switched to desktop')
await page.screenshot({ path: 'test-viewport-desktop-after.png', fullPage: true })
// Then: No React errors should occur
console.log(`Console messages: ${consoleMessages.length}`)
console.log(`Page errors: ${pageErrors.length}`)
// Filter out common warnings that are not related to the viewport switch
const relevantErrors = pageErrors.filter(error => {
const message = error.message || error.toString()
// Filter out known warnings
return !message.includes('IpcRendererEventBus') &&
!message.includes('componentWillReceiveProps') &&
!message.includes('locale') &&
!message.includes('ACE editor')
})
// Check for React-specific errors in console
const reactErrors = consoleMessages.filter(msg =>
msg.toLowerCase().includes('error') &&
(msg.includes('React') || msg.includes('react') || msg.includes('Warning:'))
)
console.log('Relevant page errors:', relevantErrors.length)
console.log('React console errors:', reactErrors.length)
if (relevantErrors.length > 0) {
console.error('Page errors detected:')
relevantErrors.forEach(err => console.error(' -', err.message))
}
if (reactErrors.length > 0) {
console.error('React errors detected:')
reactErrors.forEach(msg => console.error(' -', msg))
}
expect(relevantErrors.length, 'Should have no relevant page errors').to.equal(0)
expect(reactErrors.length, 'Should have no React console errors').to.equal(0)
})
it('should switch from desktop to mobile without React errors', async function () {
// Given: Desktop viewport (1280x720) from previous test
console.log('Current viewport: 1280x720 (desktop)')
// Clear previous errors
const pageErrors = (page as any).testPageErrors as Error[]
const consoleMessages = (page as any).testConsoleMessages as string[]
pageErrors.length = 0
consoleMessages.length = 0
// When: Switch back to mobile viewport (<768px)
console.log('Switching viewport to 412x914 (mobile)...')
await page.setViewportSize({ width: 412, height: 914 })
await sleep(2000) // Wait for resize handlers and re-renders
console.log('Viewport switched to mobile')
await page.screenshot({ path: 'test-viewport-mobile-after.png', fullPage: true })
// Then: No React errors should occur
const relevantErrors = pageErrors.filter(error => {
const message = error.message || error.toString()
return !message.includes('IpcRendererEventBus') &&
!message.includes('componentWillReceiveProps') &&
!message.includes('locale') &&
!message.includes('ACE editor')
})
const reactErrors = consoleMessages.filter(msg =>
msg.toLowerCase().includes('error') &&
(msg.includes('React') || msg.includes('react') || msg.includes('Warning:'))
)
if (relevantErrors.length > 0) {
console.error('Page errors detected:')
relevantErrors.forEach(err => console.error(' -', err.message))
}
if (reactErrors.length > 0) {
console.error('React errors detected:')
reactErrors.forEach(msg => console.error(' -', msg))
}
expect(relevantErrors.length, 'Should have no relevant page errors').to.equal(0)
expect(reactErrors.length, 'Should have no React console errors').to.equal(0)
})
it('should handle multiple rapid viewport changes', async function () {
console.log('Testing rapid viewport changes...')
// Clear previous errors
const pageErrors = (page as any).testPageErrors as Error[]
const consoleMessages = (page as any).testConsoleMessages as string[]
pageErrors.length = 0
consoleMessages.length = 0
// Rapidly switch between viewports
const viewports = [
{ width: 600, height: 800, name: 'mobile' }, // < 768
{ width: 900, height: 600, name: 'desktop' }, // > 768
{ width: 700, height: 800, name: 'mobile' }, // < 768
{ width: 1024, height: 768, name: 'desktop' }, // > 768
{ width: 412, height: 914, name: 'mobile' }, // < 768
]
for (const vp of viewports) {
console.log(`Switching to ${vp.name} (${vp.width}x${vp.height})...`)
await page.setViewportSize({ width: vp.width, height: vp.height })
await sleep(500) // Short delay between switches
}
await sleep(2000) // Final settle time
await page.screenshot({ path: 'test-viewport-rapid-changes.png', fullPage: true })
// Check for errors
const relevantErrors = pageErrors.filter(error => {
const message = error.message || error.toString()
return !message.includes('IpcRendererEventBus') &&
!message.includes('componentWillReceiveProps') &&
!message.includes('locale') &&
!message.includes('ACE editor')
})
const reactErrors = consoleMessages.filter(msg =>
msg.toLowerCase().includes('error') &&
(msg.includes('React') || msg.includes('react') || msg.includes('Warning:'))
)
if (relevantErrors.length > 0) {
console.error('Page errors detected:')
relevantErrors.forEach(err => console.error(' -', err.message))
}
if (reactErrors.length > 0) {
console.error('React errors detected:')
reactErrors.forEach(msg => console.error(' -', msg))
}
expect(relevantErrors.length, 'Should have no relevant page errors').to.equal(0)
expect(reactErrors.length, 'Should have no React console errors').to.equal(0)
})
})
})
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
-1
View File
@@ -27,7 +27,6 @@
"src/spec/testMcpIntrospection.ts",
"src/spec/ui-tests.spec.ts",
"src/spec/ui-tests-comprehensive.spec.ts",
"src/spec/viewport-switching.spec.ts",
"src/spec/expandTopic.spec.ts",
"src/spec/security-tests.spec.ts",
"src/spec/SceneBuilder.spec.ts",
+32
View File
@@ -0,0 +1,32 @@
{
"extends": ["tslint-config-airbnb", "tslint-react", "tslint-react-recommended"],
"rules": {
"semicolon": [true, "never"],
"max-line-length": [true, 200],
"member-access": true,
"no-else-after-return": false,
"align": false,
"jsx-no-lambda": false,
"indent": [true, "spaces", 2],
"import-name": false,
"no-submodule-imports": false,
"array-type": [true, "generic"],
"prefer-array-literal": false,
"function-name": false,
"ter-arrow-parens": [true, "as-needed"],
"variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"],
"no-implicit-dependencies": [true, "dev", "optional"],
"trailing-comma": [
true,
{
"multiline": {
"objects": "always",
"arrays": "always",
"functions": "never",
"typeLiterals": "ignore"
},
"esSpecCompliant": true
}
]
}
}
+208 -1859
View File
File diff suppressed because it is too large Load Diff