Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2469b7bc59 | ||
|
|
39c694510a |
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
app/build
|
||||
*.js
|
||||
!scripts/*.js
|
||||
!*.config.js
|
||||
!*.config.mjs
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -198,45 +91,3 @@ When modifying or creating UI components, follow the styling patterns documented
|
||||
- Access theme colors via `theme.palette.*`, spacing via `theme.spacing()`, typography via `theme.typography.*`
|
||||
- Support both light and dark modes with theme-conditional styling
|
||||
- Import Material-UI colors: `import { blueGrey, amber, green, red } from '@mui/material/colors'`
|
||||
|
||||
## Mobile Testing Workflow
|
||||
|
||||
**Prerequisites for mobile testing:**
|
||||
```bash
|
||||
# Install Playwright browsers
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
# Configure mosquitto to allow anonymous connections (for local testing)
|
||||
echo "listener 1883
|
||||
allow_anonymous true" | sudo tee /etc/mosquitto/conf.d/allow-anonymous.conf
|
||||
sudo systemctl restart mosquitto
|
||||
```
|
||||
|
||||
**Interactive testing with mobile viewport:**
|
||||
```bash
|
||||
# Set up environment
|
||||
export MQTT_EXPLORER_SKIP_AUTH=true
|
||||
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
|
||||
|
||||
# Build and start server
|
||||
yarn build:server
|
||||
node dist/src/server.js
|
||||
|
||||
# In another terminal, run Playwright test with mobile viewport
|
||||
# Create test script with viewport: { width: 412, height: 914 }
|
||||
# Always INSPECT the rendered output, don't rely on assumptions
|
||||
```
|
||||
|
||||
**Key lesson**: Mobile tree visibility issues often stem from:
|
||||
1. CSS flex/absolute positioning conflicts
|
||||
2. Missing Redux state updates (connection not propagated to frontend)
|
||||
3. MQTT broker authentication (mosquitto requires `allow_anonymous true` for testing)
|
||||
4. Timing issues (frontend subscribing to events after backend emits them)
|
||||
|
||||
**Server-side auto-connect** (for testing):
|
||||
```bash
|
||||
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
|
||||
export MQTT_AUTO_CONNECT_PORT=1883 # optional
|
||||
export MQTT_AUTO_CONNECT_PROTOCOL=mqtt # optional
|
||||
```
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ on:
|
||||
- Dockerfile
|
||||
- .github
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
create-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -22,10 +22,6 @@ on:
|
||||
- cron: '0 2 1,15 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,20 +38,13 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install and Start Mosquitto
|
||||
- name: Install mosquitto
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
|
||||
# Create a minimal configuration file for testing
|
||||
sudo tee /etc/mosquitto/conf.d/test.conf > /dev/null <<EOF
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
persistence false
|
||||
EOF
|
||||
|
||||
# Start mosquitto in detached mode
|
||||
sudo mosquitto -c /etc/mosquitto/mosquitto.conf -d
|
||||
sudo apt-get install -y mosquitto
|
||||
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /mosquitto/config/mosquitto.conf -d || mosquitto -d
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -2,10 +2,6 @@ on:
|
||||
pull_request_target: # Use pull_request_target
|
||||
branches: [master, beta, release]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,8 +16,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
|
||||
@@ -33,10 +27,16 @@ jobs:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: mosquitto
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build Browser Mode
|
||||
@@ -59,10 +59,16 @@ jobs:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: localhost
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
@@ -163,135 +169,13 @@ jobs:
|
||||
body: markdown
|
||||
});
|
||||
|
||||
demo-video-mobile:
|
||||
test-browser:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build Browser Mode
|
||||
run: yarn build:server
|
||||
- name: Generate Mobile Demo Video
|
||||
id: generate_video
|
||||
continue-on-error: true
|
||||
run: ./scripts/uiTestsMobile.sh
|
||||
- name: Post-processing
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: ./scripts/prepareVideoMobile.sh
|
||||
- name: Generate unique base path
|
||||
id: basepath
|
||||
run: |
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BASEPATH="pr-${{ github.event.pull_request.number }}-mobile-${TIMESTAMP}"
|
||||
echo "basepath=${BASEPATH}" >> $GITHUB_OUTPUT
|
||||
- name: Install AWS CLI v2
|
||||
run: |
|
||||
apt-get update && apt-get install -y unzip
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
./aws/install
|
||||
rm -rf aws awscliv2.zip
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: 'eu-central-1'
|
||||
- name: Upload full mobile video to S3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
run: |
|
||||
# Upload GIF if it exists
|
||||
if [ -f ./ui-test-mobile.gif ]; then
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/ui-test-mobile.gif \
|
||||
--body ./ui-test-mobile.gif \
|
||||
--content-type image/gif
|
||||
fi
|
||||
|
||||
# Upload MP4 if it exists
|
||||
if [ -f ./ui-test-mobile.mp4 ]; then
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/ui-test-mobile.mp4 \
|
||||
--body ./ui-test-mobile.mp4 \
|
||||
--content-type video/mp4
|
||||
fi
|
||||
- name: Upload mobile video segments to S3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Upload all mobile GIF segment files if they exist
|
||||
shopt -s nullglob # Make glob return empty list if no matches
|
||||
for segment in segment-mobile-*.gif; do
|
||||
echo "Uploading $segment..."
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/${segment} \
|
||||
--body ./${segment} \
|
||||
--content-type image/gif
|
||||
done
|
||||
shopt -u nullglob # Restore default behavior
|
||||
- name: Generate file URLs
|
||||
if: always()
|
||||
id: fileurl
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
run: |
|
||||
BASE_URL="https://${AWS_BUCKET}.s3.eu-central-1.amazonaws.com/artifacts/${BASEPATH}"
|
||||
echo "base-url=${BASE_URL}" >> $GITHUB_OUTPUT
|
||||
echo "Uploaded to: ${BASE_URL}"
|
||||
- name: Generate markdown summary
|
||||
if: always()
|
||||
id: markdown
|
||||
env:
|
||||
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
|
||||
TEST_STATUS: ${{ steps.generate_video.outcome }}
|
||||
run: |
|
||||
MARKDOWN=$(node ./scripts/generateMarkdownSummaryMobile.js "${BASE_URL}" "${TEST_STATUS}")
|
||||
echo "markdown<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$MARKDOWN" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Add to workflow summary
|
||||
if: always()
|
||||
env:
|
||||
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
|
||||
run: |
|
||||
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
|
||||
- name: Post mobile video to PR
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const markdown = process.env.MARKDOWN;
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: markdown
|
||||
});
|
||||
|
||||
test-browser:
|
||||
runs-on: ubuntu-latest
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: localhost
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
@@ -299,14 +183,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Install Playwright Browsers
|
||||
@@ -317,5 +195,26 @@ jobs:
|
||||
run: yarn test:app
|
||||
- name: Test Backend
|
||||
run: yarn test:backend
|
||||
- name: Run Browser UI Tests
|
||||
run: ./scripts/runBrowserTests.sh
|
||||
- name: Start Server in Background
|
||||
run: |
|
||||
yarn start:server &
|
||||
echo $! > server.pid
|
||||
env:
|
||||
MQTT_EXPLORER_USERNAME: test
|
||||
MQTT_EXPLORER_PASSWORD: test123
|
||||
PORT: 3000
|
||||
- name: Wait for Server
|
||||
run: |
|
||||
timeout 30 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
|
||||
- name: Browser Smoke Test
|
||||
run: |
|
||||
# Test server is running
|
||||
curl -f http://localhost:3000 || exit 1
|
||||
echo "Browser mode server is running successfully"
|
||||
- name: Stop Server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f server.pid ]; then
|
||||
kill $(cat server.pid) || true
|
||||
rm server.pid
|
||||
fi
|
||||
|
||||
@@ -2,10 +2,6 @@ name: Update Website
|
||||
|
||||
on: [release, workflow_dispatch]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-website:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -27,44 +27,12 @@ app/.webpack-cache
|
||||
|
||||
# Demo video artifacts
|
||||
scenes.json
|
||||
scenes-mobile.json
|
||||
segment-*.mp4
|
||||
segment-*.gif
|
||||
ui-test.mp4
|
||||
ui-test.gif
|
||||
ui-test-mobile.mp4
|
||||
ui-test-mobile.gif
|
||||
app.mp4
|
||||
app2.mp4
|
||||
app-mobile.mp4
|
||||
app2-mobile.mp4
|
||||
app720.gif
|
||||
qrawvideorgb24.yuv
|
||||
qrawvideorgb24-mobile.yuv
|
||||
intro.png
|
||||
intro-mobile.png
|
||||
palette.png
|
||||
palette-mobile.png
|
||||
ffmpeg_info
|
||||
ffmpeg_info_mobile# Mobile test artifacts
|
||||
qrawvideorgb24-mobile.yuv
|
||||
*.yuv
|
||||
segment-mobile-*.gif
|
||||
mobile-demo.mp4
|
||||
mobile-demo.gif
|
||||
final-mobile-tree.png
|
||||
mobile-tree-debug.png
|
||||
mobile-render-debug.png
|
||||
tree-state-check.png
|
||||
server*.log
|
||||
|
||||
# Test scripts
|
||||
test-mobile-tree.js
|
||||
check-*.js
|
||||
debug-*.js
|
||||
inspect-*.js
|
||||
final-*.js
|
||||
publish-test*.js
|
||||
verify-mobile-tree.js
|
||||
interactive-mobile-test.js
|
||||
long-wait-test.js
|
||||
intro.png
|
||||
@@ -93,21 +93,20 @@ Tests the traditional Electron desktop application:
|
||||
Tests the new browser/server mode:
|
||||
|
||||
- **Environment**: Ubuntu latest with Node.js 24
|
||||
- **MQTT Broker**: Mosquitto v2 on port 1883
|
||||
- Started detached with `-d` flag
|
||||
- Anonymous connections allowed
|
||||
- No persistence
|
||||
- **Services**:
|
||||
- **Mosquitto MQTT Broker**: Eclipse Mosquitto v2 on port 1883
|
||||
- Health checks enabled
|
||||
- Anonymous connections allowed
|
||||
- **Steps**:
|
||||
1. Install and start Mosquitto in detached mode
|
||||
2. Setup Node.js 24
|
||||
3. Install dependencies
|
||||
4. Install Playwright browsers (`npx playwright install --with-deps chromium`)
|
||||
5. Build browser mode (`yarn build:server`)
|
||||
6. Run unit tests (app + backend)
|
||||
7. Start server in background with test credentials
|
||||
8. Wait for server to be ready
|
||||
9. Run browser smoke tests
|
||||
10. Clean up server process
|
||||
1. Setup Node.js 24
|
||||
2. Install dependencies
|
||||
3. Install Playwright browsers (`npx playwright install --with-deps chromium`)
|
||||
4. Build browser mode (`yarn build:server`)
|
||||
5. Run unit tests (app + backend)
|
||||
6. Start server in background with test credentials
|
||||
7. Wait for server to be ready
|
||||
8. Run browser smoke tests
|
||||
9. Clean up server process
|
||||
|
||||
**Environment Variables**:
|
||||
- `MQTT_EXPLORER_USERNAME=test`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Multi-stage build for MQTT Explorer Browser Mode
|
||||
# Stage 1: Build and prepare production dependencies
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN yarn install --production --frozen-lockfile --network-timeout 100000 && \
|
||||
rm -rf /tmp/*
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
# Install dumb-init in a single layer
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
When distributing, the attribution and donation page may not be altered or made less accessible without explicit approval.
|
||||
|
||||
# Creative Commons Attribution-NoDerivatives 4.0 International
|
||||
# Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
@@ -12,33 +12,37 @@ Creative Commons public licenses provide a standard set of terms and conditions
|
||||
|
||||
* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
|
||||
|
||||
## Creative Commons Attribution-NoDerivatives 4.0 International Public License
|
||||
## Creative Commons Attribution-ShareAlike 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
### Section 1 – Definitions.
|
||||
|
||||
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||
|
||||
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
|
||||
e. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and NoDerivatives.
|
||||
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
|
||||
f. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
|
||||
g. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
|
||||
|
||||
h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
|
||||
i. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
|
||||
k. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
|
||||
k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
|
||||
l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
|
||||
m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
|
||||
|
||||
### Section 2 – Scope.
|
||||
|
||||
@@ -46,9 +50,9 @@ a. ___License grant.___
|
||||
|
||||
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; but not
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||
|
||||
B. produce, reproduce, or Share Adapted Material.
|
||||
B. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||
|
||||
@@ -60,8 +64,9 @@ a. ___License grant.___
|
||||
|
||||
A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||
|
||||
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
B. __Additional offer from the Licensor – Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
|
||||
|
||||
C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
|
||||
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
@@ -101,13 +106,23 @@ a. ___Attribution.___
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
|
||||
b. ___ShareAlike.___
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
|
||||
|
||||
### Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
|
||||
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -1,156 +0,0 @@
|
||||
# Mobile Compatibility Concept
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the mobile compatibility strategy for MQTT Explorer, focusing on providing a good mobile experience without requiring a complete UI rewrite.
|
||||
|
||||
## Target Device
|
||||
|
||||
**Reference Device:** Google Pixel 6
|
||||
- Viewport: 412x915 pixels (portrait)
|
||||
- Typical modern smartphone dimensions
|
||||
- Good representation of common mobile browsers
|
||||
|
||||
## Strategy
|
||||
|
||||
### 1. Browser Mode First
|
||||
Mobile compatibility focuses on the browser mode (`yarn dev:server`) rather than native mobile apps, as:
|
||||
- Browser mode already supports any device with a modern web browser
|
||||
- No app store deployment complexities
|
||||
- Users can access via mobile browser or save as PWA
|
||||
|
||||
### 2. Responsive Design Enhancements
|
||||
|
||||
Without rewriting the UI, we implement strategic responsive improvements:
|
||||
|
||||
#### Viewport Configuration
|
||||
- Ensure proper viewport meta tag for mobile scaling
|
||||
- Already present: `<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />`
|
||||
|
||||
#### Layout Adaptations
|
||||
- **Tree Panel**: Make touch-friendly (larger tap targets, better scrolling)
|
||||
- **Sidebar**: Collapsible by default on mobile, swipe-friendly
|
||||
- **Chart Panel**: Stack vertically instead of side-by-side
|
||||
- **Split Panes**: Adjust minimum sizes and default positions for mobile
|
||||
|
||||
#### Touch Interactions
|
||||
- Increase tap target sizes for mobile (minimum 44x44px)
|
||||
- Improve scrolling performance
|
||||
- Add touch-friendly gestures where applicable
|
||||
|
||||
### 3. Minimal CSS Changes
|
||||
|
||||
Use CSS media queries to adapt the UI for mobile viewports:
|
||||
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile-specific overrides */
|
||||
}
|
||||
```
|
||||
|
||||
Key areas for CSS adjustments:
|
||||
- Typography sizing (ensure readability on small screens)
|
||||
- Padding and margins (optimize for touch)
|
||||
- Button and icon sizes (larger for touch targets)
|
||||
- Navigation (hamburger menu, collapsible sections)
|
||||
|
||||
### 4. Feature Prioritization
|
||||
|
||||
On mobile devices, prioritize:
|
||||
1. **Core Functionality**: View topics, read messages, basic navigation
|
||||
2. **Search**: Easy topic filtering and search
|
||||
3. **Connection Management**: Connect/disconnect, basic settings
|
||||
4. **Publishing**: Simple message publishing
|
||||
|
||||
Less critical on mobile (can be de-emphasized):
|
||||
- Advanced connection settings (can use smaller text/collapse)
|
||||
- Extensive keyboard shortcuts
|
||||
- Multi-panel simultaneous viewing
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Foundation (Current)
|
||||
- Document mobile compatibility concept ✓
|
||||
- Create mobile demo video showing current experience
|
||||
- Identify pain points and opportunities
|
||||
|
||||
### Phase 2: Quick Wins (Minimal Changes)
|
||||
- Adjust default split pane positions for mobile
|
||||
- Increase touch target sizes in critical areas
|
||||
- Improve sidebar collapse behavior on small screens
|
||||
- Optimize tree node spacing for touch
|
||||
|
||||
### Phase 3: Enhanced Experience (Future)
|
||||
- Add PWA manifest for "add to home screen"
|
||||
- Implement swipe gestures
|
||||
- Optimize connection dialog for mobile
|
||||
- Add mobile-specific keyboard (numeric for ports, etc.)
|
||||
|
||||
## Demo Video
|
||||
|
||||
### Purpose
|
||||
Create a demonstration video showing MQTT Explorer running on a mobile viewport (Pixel 6 dimensions) to:
|
||||
- Showcase current mobile experience
|
||||
- Identify UX issues
|
||||
- Demonstrate the feasibility of mobile usage
|
||||
- Guide future improvements
|
||||
|
||||
### Technical Implementation
|
||||
- Use Playwright with Chromium in mobile emulation mode
|
||||
- Viewport size: 412x915 (Pixel 6 portrait)
|
||||
- Record typical mobile use cases:
|
||||
- Connecting to broker
|
||||
- Browsing topic tree (with touch gestures)
|
||||
- Viewing message details
|
||||
- Searching topics
|
||||
- Publishing messages
|
||||
|
||||
### Script Location
|
||||
`src/spec/demoVideoMobile.ts` - Mobile-specific demo video script
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
- Test on real mobile devices (iOS Safari, Android Chrome)
|
||||
- Use Chrome DevTools device emulation during development
|
||||
- Verify touch interactions work smoothly
|
||||
|
||||
### Automated Testing
|
||||
- Create mobile-specific UI tests
|
||||
- Run demo video generation with mobile viewport
|
||||
- Validate responsive breakpoints
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
Add PWA capabilities:
|
||||
- Service worker for offline support
|
||||
- App manifest for installability
|
||||
- App icon and splash screen
|
||||
|
||||
### Platform-Specific Optimizations
|
||||
- iOS: Handle safe areas, notch
|
||||
- Android: Material Design guidelines
|
||||
- Dark mode (already supported via theme)
|
||||
|
||||
### Performance
|
||||
- Optimize bundle size for mobile networks
|
||||
- Implement lazy loading for large topic trees
|
||||
- Add connection retry logic for unreliable mobile networks
|
||||
|
||||
## Metrics for Success
|
||||
|
||||
A successful mobile experience should provide:
|
||||
- ✅ All core features accessible on mobile
|
||||
- ✅ No horizontal scrolling required
|
||||
- ✅ Touch targets minimum 44x44px
|
||||
- ✅ Readable text without zooming
|
||||
- ✅ Smooth scrolling and interactions
|
||||
- ✅ Quick load times (<3s on 3G)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Google Mobile-Friendly Test](https://search.google.com/test/mobile-friendly)
|
||||
- [Material Design Touch Target Guidelines](https://material.io/design/usability/accessibility.html#layout-typography)
|
||||
- [MDN Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design)
|
||||
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
|
||||
@@ -1,180 +0,0 @@
|
||||
# Mobile Testing Guide
|
||||
|
||||
This document describes how to run and debug mobile UI tests for MQTT Explorer.
|
||||
|
||||
## Overview
|
||||
|
||||
The mobile tests simulate MQTT Explorer running in a mobile browser (Google Pixel 6 viewport: 412x914px) and generate demo videos showing the mobile user experience.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### System Dependencies
|
||||
```bash
|
||||
sudo apt-get install -y ffmpeg tmux xvfb x11vnc mosquitto
|
||||
```
|
||||
|
||||
### Node Dependencies
|
||||
```bash
|
||||
yarn install
|
||||
npx playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
## Running Mobile Tests
|
||||
|
||||
### 1. Build the Application
|
||||
```bash
|
||||
yarn build:server # For browser mode
|
||||
```
|
||||
|
||||
### 2. Run Tests
|
||||
```bash
|
||||
./scripts/uiTestsMobile.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Start Xvfb (virtual framebuffer)
|
||||
- Start mosquitto MQTT broker
|
||||
- Start MQTT Explorer server in browser mode
|
||||
- Run Playwright tests with mobile viewport
|
||||
- Record video of the test session
|
||||
|
||||
### 3. Post-Process Video
|
||||
```bash
|
||||
./scripts/prepareVideoMobile.sh
|
||||
```
|
||||
|
||||
This converts the raw video to MP4 and GIF formats and creates individual segments for each test scene.
|
||||
|
||||
## Output Files
|
||||
|
||||
- `ui-test-mobile.mp4` - Full mobile test video (MP4)
|
||||
- `ui-test-mobile.gif` - Full mobile test video (GIF)
|
||||
- `segment-mobile-*.gif` - Individual scene segments
|
||||
- `scenes-mobile.json` - Scene timing metadata
|
||||
|
||||
All video files are automatically excluded from git (see `.gitignore`).
|
||||
|
||||
## Debugging
|
||||
|
||||
### View Test in VNC
|
||||
|
||||
During test execution, you can connect with VNC to watch in real-time:
|
||||
```bash
|
||||
# Password: bierbier
|
||||
vncviewer localhost:5900
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Playwright Browsers Not Installed
|
||||
**Error:** `Executable doesn't exist at .../chromium_headless_shell-1200/chrome-headless-shell`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
npx playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
#### 2. Video Encoding Fails
|
||||
**Error:** `height not divisible by 2`
|
||||
|
||||
**Solution:** Ensure viewport height is even. Mobile viewport is set to 412x914 (not 915).
|
||||
|
||||
#### 3. Elements Outside Viewport
|
||||
**Error:** `element is outside of the viewport`
|
||||
|
||||
**Solution:** The fix adds `scrollIntoViewIfNeeded()` before clicking elements. For modal/dialog elements that intercept clicks, use `force: true`.
|
||||
|
||||
#### 4. MQTT Broker Already Running
|
||||
**Error:** `Address already in use` on port 1883
|
||||
|
||||
**Solution:** Kill existing mosquitto process:
|
||||
```bash
|
||||
pkill mosquitto
|
||||
```
|
||||
|
||||
## Mobile UI Enhancements
|
||||
|
||||
The tests revealed several mobile UI improvements that were implemented:
|
||||
|
||||
### 1. Connection Dialog Responsiveness
|
||||
Added responsive CSS to `ConnectionSetup.tsx`:
|
||||
- Mobile viewports use 95vw width and 85vh height
|
||||
- Enabled scrolling on the right panel
|
||||
- Hide profile list on mobile to save space
|
||||
|
||||
### 2. Click Handling
|
||||
Enhanced `clickOn` helper in `util/index.ts`:
|
||||
- Added `scrollIntoViewIfNeeded()` to ensure elements are in viewport
|
||||
- Support for `force: true` to bypass overlay elements
|
||||
|
||||
### 3. Tree Node Expansion
|
||||
Updated `expandTopic` helper:
|
||||
- Use `force: true` for tree clicks to bypass accordion overlays
|
||||
- Better handling of nested topic expansion
|
||||
|
||||
## Test Scenes
|
||||
|
||||
The mobile demo includes these scenes:
|
||||
|
||||
1. **mobile_intro** - Introduction screen
|
||||
2. **mobile_connect** - Connect to MQTT broker
|
||||
3. **mobile_browse_topics** - Browse topic tree
|
||||
4. **mobile_search** - Search and filter topics
|
||||
5. **mobile_view_message** - View message details
|
||||
6. **mobile_json_view** - JSON formatting display
|
||||
7. **mobile_clipboard** - Copy operations
|
||||
8. **mobile_plots** - Numeric data visualization
|
||||
9. **mobile_menu** - Settings and menu
|
||||
10. **mobile_end** - Conclusion screen
|
||||
|
||||
## CI Integration
|
||||
|
||||
Mobile tests run in the `demo-video-mobile` job in `.github/workflows/tests.yml`:
|
||||
|
||||
```yaml
|
||||
demo-video-mobile:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
steps:
|
||||
- name: Generate Mobile Demo Video
|
||||
run: ./scripts/uiTestsMobile.sh
|
||||
- name: Post-processing
|
||||
run: ./scripts/prepareVideoMobile.sh
|
||||
```
|
||||
|
||||
Videos are uploaded to S3 and linked in PR comments.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Viewport Configuration
|
||||
- **Width:** 412px (Pixel 6)
|
||||
- **Height:** 914px (must be even for h264 encoding)
|
||||
- **Device Scale Factor:** 2.625
|
||||
- **Mobile Mode:** Enabled with touch events
|
||||
|
||||
### Video Recording
|
||||
- Raw video: YUV420P format
|
||||
- Frame rate: 20 fps
|
||||
- Recording tool: ffmpeg via tmux
|
||||
|
||||
### Post-Processing
|
||||
- MP4 encoding: h264 codec
|
||||
- GIF palette: 256 colors optimized per segment
|
||||
- Segment creation: Based on scene timing in `scenes-mobile.json`
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential areas for enhancement:
|
||||
|
||||
1. **Touch Gestures** - Add swipe and pinch interactions
|
||||
2. **Performance** - Optimize for slower mobile networks
|
||||
3. **Accessibility** - Larger touch targets, better contrast
|
||||
4. **PWA Support** - Add manifest for "add to home screen"
|
||||
5. **Orientation** - Test landscape mode
|
||||
|
||||
## References
|
||||
|
||||
- [MOBILE_COMPATIBILITY.md](./MOBILE_COMPATIBILITY.md) - Mobile compatibility strategy
|
||||
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
|
||||
- [Material-UI Responsive Design](https://mui.com/material-ui/customization/breakpoints/)
|
||||
@@ -187,34 +187,6 @@ yarn build
|
||||
|
||||
This script handles Xvfb setup, mosquitto startup, video recording, and cleanup.
|
||||
|
||||
### Mobile Demo Video
|
||||
|
||||
A mobile-focused demo video showcases MQTT Explorer in a mobile viewport (Pixel 6: 412x915px):
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
yarn test:demo-video:mobile
|
||||
```
|
||||
|
||||
Or with full recording setup:
|
||||
```bash
|
||||
yarn build
|
||||
./scripts/uiTestsMobile.sh
|
||||
```
|
||||
|
||||
This demonstrates the mobile compatibility features and responsive design improvements. See [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md) for the mobile strategy and implementation details.
|
||||
|
||||
## Mobile Compatibility
|
||||
|
||||
MQTT Explorer supports mobile devices through its browser mode with responsive design enhancements:
|
||||
|
||||
- **Target Device**: Google Pixel 6 (412x915px viewport)
|
||||
- **Touch-Friendly UI**: Minimum 44px tap targets for better mobile UX
|
||||
- **Responsive Layout**: Sidebar and panels adapt to mobile viewports
|
||||
- **Browser Mode**: Access via mobile browser or install as PWA
|
||||
|
||||
For the complete mobile compatibility concept, implementation phases, and future roadmap, see [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md).
|
||||
|
||||
## Create a release
|
||||
|
||||
Create a PR to `release` branch.
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
@@ -18,49 +18,6 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Mobile-specific responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
/* Increase touch target sizes for better mobile UX */
|
||||
button {
|
||||
min-height: 44px !important;
|
||||
min-width: 44px !important;
|
||||
}
|
||||
|
||||
/* Make icons larger on mobile */
|
||||
svg {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Improve tree node tap targets */
|
||||
[data-testid="tree-node"] {
|
||||
min-height: 44px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
/* Better mobile typography */
|
||||
body {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection on mobile taps - applied to interactive elements */
|
||||
button, a, [role="button"], [data-testid] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Improve scrolling performance for scrollable containers */
|
||||
[style*="overflow"], .MuiDrawer-root, [data-testid="tree-container"] {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Make resizers more visible on mobile */
|
||||
.Resizer.vertical::before,
|
||||
.Resizer.horizontal::before {
|
||||
font-size: 1.5rem !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes updateDark {
|
||||
0% {
|
||||
background-color: none;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { globalActions } from '.'
|
||||
import { resetStore as resetTreeStore, showTree } from './Tree'
|
||||
import { showError } from './Global'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../eventBus'
|
||||
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../events'
|
||||
|
||||
export const connect =
|
||||
(options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as path from 'path'
|
||||
import { ActionTypes, Action } from '../reducers/ConnectionManager'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
import { connectionsMigrator } from './migrations/Connection'
|
||||
import { rendererRpc, readFromFile } from '../eventBus'
|
||||
import { rendererRpc, readFromFile } from '../../../events'
|
||||
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
|
||||
|
||||
export interface ConnectionDictionary {
|
||||
@@ -46,9 +46,6 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
|
||||
const firstKey = Object.keys(connections)[0]
|
||||
if (firstKey) {
|
||||
dispatch(selectConnection(firstKey))
|
||||
} else {
|
||||
// No connections exist - create a default one
|
||||
dispatch(createConnection())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +2,7 @@ import { Action, ActionTypes } from '../reducers/Publish'
|
||||
import { AppState } from '../reducers'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
import { Dispatch } from 'redux'
|
||||
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../eventBus'
|
||||
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../../../events'
|
||||
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
|
||||
import { showError } from './Global'
|
||||
import { Base64 } from 'js-base64'
|
||||
|
||||
@@ -12,8 +12,6 @@ export { clearTopic } from './clearTopic'
|
||||
|
||||
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
|
||||
|
||||
export { setMobileTab } from './Global'
|
||||
|
||||
export const selectTopic =
|
||||
(topic: q.TreeNode<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
debouncedSelectTopic(topic, dispatch, getState)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { makePublishEvent, rendererEvents } from '../eventBus'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
|
||||
import { globalActions } from '.'
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Auto-connect handler for browser mode
|
||||
// This file is loaded early in the app initialization to handle server-initiated auto-connect
|
||||
|
||||
import { store } from './store'
|
||||
import * as q from '../../backend/src/Model'
|
||||
import { TopicViewModel } from './model/TopicViewModel'
|
||||
import { showTree } from './actions/Tree'
|
||||
import { connecting, connected } from './actions/Connection'
|
||||
import { makeConnectionStateEvent, rendererEvents } from './eventBus'
|
||||
import { DataSourceState } from '../../backend/src/DataSource'
|
||||
|
||||
// Listen for auto-connect-initiated event from server
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('mqtt-auto-connect-initiated', ((event: CustomEvent) => {
|
||||
const { connectionId } = event.detail
|
||||
console.log('Auto-connect initiated from server, connectionId:', connectionId)
|
||||
|
||||
// Dispatch connecting action
|
||||
store.dispatch(connecting(connectionId) as any)
|
||||
console.log('Dispatched connecting action')
|
||||
|
||||
// Subscribe to connection state events
|
||||
const stateEvent = makeConnectionStateEvent(connectionId)
|
||||
console.log('Subscribing to connection state event:', stateEvent)
|
||||
|
||||
rendererEvents.subscribe(stateEvent, (dataSourceState: DataSourceState) => {
|
||||
console.log('Auto-connect state update:', JSON.stringify(dataSourceState, null, 2))
|
||||
|
||||
if (dataSourceState.connected) {
|
||||
console.log('Auto-connect: connection established!')
|
||||
const state = store.getState()
|
||||
const didReconnect = Boolean(state.connection.tree)
|
||||
if (!didReconnect) {
|
||||
// Create tree and update with connection
|
||||
console.log('Creating tree for connection:', connectionId)
|
||||
const tree = new q.Tree<TopicViewModel>()
|
||||
tree.updateWithConnection(rendererEvents, connectionId)
|
||||
store.dispatch(showTree(tree) as any)
|
||||
store.dispatch(connected(tree, 'auto-connect') as any)
|
||||
console.log('Auto-connect successful, tree created and dispatched')
|
||||
}
|
||||
} else if (dataSourceState.error) {
|
||||
console.error('Auto-connect error:', dataSourceState.error)
|
||||
}
|
||||
})
|
||||
console.log('Auto-connect handler setup complete')
|
||||
}) as EventListener)
|
||||
}
|
||||
@@ -72,30 +72,6 @@ socket.on('auth-status', (data: { authDisabled: boolean }) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for auto-connect configuration from server
|
||||
socket.on('auto-connect-config', (config: any) => {
|
||||
console.log('Auto-connect configuration received from server')
|
||||
|
||||
// Dispatch custom event with auto-connect config
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-config', {
|
||||
detail: config
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for auto-connect-initiated event from server
|
||||
socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
|
||||
console.log('Auto-connect initiated by server, connectionId:', data.connectionId)
|
||||
|
||||
// Dispatch custom event to trigger connection flow
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-initiated', {
|
||||
detail: data
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update socket authentication credentials and attempt to reconnect
|
||||
* @param newUsername New username
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ const styles = (theme: Theme) => ({
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
overflow: 'hidden scroll',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { rendererRpc } from '../../eventBus'
|
||||
import { rendererRpc } from '../../../../events'
|
||||
import { RpcEvents } from '../../../../events/EventsV2'
|
||||
|
||||
function BrowserCertificateFileSelection(props: {
|
||||
|
||||
@@ -8,29 +8,15 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
|
||||
|
||||
if (connecting) {
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}
|
||||
onClick={toggle}
|
||||
data-testid="abort-button"
|
||||
aria-label="Cancel connection attempt"
|
||||
>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="abort-button">
|
||||
<ConnectionHealthIndicator />
|
||||
Cancel
|
||||
Abort
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}
|
||||
onClick={toggle}
|
||||
data-testid="connect-button"
|
||||
aria-label="Connect to MQTT broker"
|
||||
>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="connect-button">
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, connectionManagerActions, globalActions } from '../../actions'
|
||||
import { connectionActions, connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
@@ -17,12 +17,14 @@ import { ToggleSwitch } from './ToggleSwitch'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
@@ -30,7 +32,6 @@ interface Props {
|
||||
classes: { [s: string]: string }
|
||||
actions: typeof connectionActions
|
||||
managerActions: typeof connectionManagerActions
|
||||
globalActions: typeof globalActions
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
}
|
||||
@@ -40,17 +41,6 @@ const protocols = ['mqtt', 'ws']
|
||||
function ConnectionSettings(props: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const confirmed = await props.globalActions.requestConfirmation(
|
||||
'Delete Connection',
|
||||
`Are you sure you want to delete the connection "${props.connection.name}"?\n\nThis action cannot be undone.`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
props.managerActions.deleteConnection(props.connection.id)
|
||||
}
|
||||
}, [props.connection.id, props.connection.name, props.globalActions, props.managerActions])
|
||||
|
||||
const toggleConnect = useCallback(() => {
|
||||
if (props.connecting) {
|
||||
props.actions.disconnect()
|
||||
@@ -86,7 +76,7 @@ function ConnectionSettings(props: Props) {
|
||||
className={props.classes.textField}
|
||||
value={props.connection.basePath}
|
||||
onChange={handleChange('basePath')}
|
||||
margin="dense"
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
@@ -111,26 +101,21 @@ function ConnectionSettings(props: Props) {
|
||||
|
||||
const protocolItems = protocols.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
|
||||
{value}://
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
return (
|
||||
<Tooltip title="Use 'mqtt' for standard connections or 'ws' for WebSocket connections" arrow>
|
||||
<TextField
|
||||
select={true}
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={connection.protocol}
|
||||
onChange={updateProtocol}
|
||||
margin="dense"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT protocol'
|
||||
}}
|
||||
>
|
||||
{protocolItems}
|
||||
</TextField>
|
||||
</Tooltip>
|
||||
<TextField
|
||||
select={true}
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={connection.protocol}
|
||||
onChange={updateProtocol}
|
||||
margin="normal"
|
||||
>
|
||||
{protocolItems}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,15 +144,9 @@ function ConnectionSettings(props: Props) {
|
||||
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={props.showPassword ? "Hide password" : "Show password"} arrow>
|
||||
<IconButton
|
||||
aria-label={props.showPassword ? "Hide password" : "Show password"}
|
||||
onClick={props.toggle}
|
||||
edge="end"
|
||||
>
|
||||
{props.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton aria-label="Toggle password visibility" onClick={props.toggle}>
|
||||
{props.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
@@ -175,9 +154,9 @@ function ConnectionSettings(props: Props) {
|
||||
const { classes, connection } = props
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Grid container={true} spacing={2}>
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
autoFocus={true}
|
||||
@@ -185,11 +164,7 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.name}
|
||||
onChange={handleChange('name')}
|
||||
margin="dense"
|
||||
placeholder="My MQTT Connection"
|
||||
inputProps={{
|
||||
'aria-label': 'Connection name'
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
@@ -212,12 +187,7 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.host}
|
||||
onChange={handleChange('host')}
|
||||
margin="dense"
|
||||
placeholder="broker.example.com"
|
||||
inputProps={{
|
||||
'data-testid': 'host-input',
|
||||
'aria-label': 'MQTT broker host'
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
@@ -226,14 +196,7 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.port}
|
||||
onChange={handleChange('port')}
|
||||
margin="dense"
|
||||
type="number"
|
||||
placeholder="1883"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT broker port',
|
||||
min: 1,
|
||||
max: 65535
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
{requiresBasePath() ? renderBasePathInput() : null}
|
||||
@@ -243,74 +206,54 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.username}
|
||||
onChange={handleChange('username')}
|
||||
margin="dense"
|
||||
placeholder="Optional"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT username',
|
||||
'autoComplete': 'username'
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<TextField
|
||||
label="Password"
|
||||
className={classes.textField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={handleChange('password')}
|
||||
margin="dense"
|
||||
placeholder="Optional"
|
||||
InputProps={{
|
||||
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />
|
||||
}}
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT password',
|
||||
'autoComplete': 'current-password'
|
||||
}}
|
||||
/>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={handleChange('password')}
|
||||
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(0, 0, 0, 0.12)' }}>
|
||||
<br />
|
||||
<div>
|
||||
<Tooltip title="Delete this connection permanently" arrow>
|
||||
<div style={{ float: 'left' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
className={classes.button}
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete connection"
|
||||
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
|
||||
>
|
||||
<Delete /> Delete
|
||||
Delete <Delete />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Advanced connection settings" arrow>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="advanced-button"
|
||||
aria-label="Show advanced settings"
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip title="Save connection settings" arrow>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.saveConnectionSettings}
|
||||
aria-label="Save connection"
|
||||
>
|
||||
<Save /> Save
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
|
||||
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -326,7 +269,6 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionActions, dispatch),
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
globalActions: bindActionCreators(globalActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from 'react'
|
||||
import ConnectionSettings from './ConnectionSettings'
|
||||
const ConnectionSettingsAny = ConnectionSettings as any
|
||||
import ProfileList from './ProfileList'
|
||||
import MobileConnectionSelector from './MobileConnectionSelector'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -67,15 +66,10 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
|
||||
</div>
|
||||
<div className={classes.right} key={connection && connection.id}>
|
||||
<Toolbar>
|
||||
<div className={classes.toolbarContent}>
|
||||
<div className={classes.desktopTitle}>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
MQTT Connection
|
||||
</Typography>
|
||||
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
|
||||
</div>
|
||||
<MobileConnectionSelector />
|
||||
</div>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
MQTT Connection
|
||||
</Typography>
|
||||
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
|
||||
</Toolbar>
|
||||
{this.renderSettings()}
|
||||
</div>
|
||||
@@ -92,20 +86,6 @@ const styles = (theme: Theme) => ({
|
||||
color: theme.palette.text.primary,
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
toolbarContent: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
desktopTitle: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
// Hide on mobile - connection selector will take its place
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
root: {
|
||||
margin: `calc((100vh - ${connectionHeight}) / 2) auto 0 auto`,
|
||||
minWidth: '800px',
|
||||
@@ -113,14 +93,6 @@ const styles = (theme: Theme) => ({
|
||||
height: connectionHeight,
|
||||
outline: 'none' as 'none',
|
||||
display: 'flex' as 'flex',
|
||||
// Mobile responsive adjustments
|
||||
[theme.breakpoints.down('md')]: {
|
||||
minWidth: '95vw',
|
||||
maxWidth: '95vw',
|
||||
height: '85vh',
|
||||
margin: '7.5vh auto 0 auto',
|
||||
flexDirection: 'column' as 'column',
|
||||
},
|
||||
},
|
||||
left: {
|
||||
borderRightStyle: 'dotted' as 'dotted',
|
||||
@@ -131,21 +103,12 @@ const styles = (theme: Theme) => ({
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
overflowY: 'auto' as 'auto',
|
||||
// Mobile: hide profile list to save space
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
right: {
|
||||
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
padding: theme.spacing(2),
|
||||
flex: 10,
|
||||
// Mobile: enable scrolling
|
||||
[theme.breakpoints.down('md')]: {
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
},
|
||||
connectionUri: {
|
||||
width: '27em',
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import Add from '@mui/icons-material/Add'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
container: {
|
||||
display: 'none',
|
||||
// Only show on mobile, takes full width
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
flex: 1,
|
||||
fontSize: '1rem',
|
||||
'& .MuiSelect-select': {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
addButton: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
connections: Array<{ id: string; name?: string; host?: string }>
|
||||
currentConnectionId?: string
|
||||
isConnected: boolean
|
||||
currentActiveConnectionId?: string
|
||||
actions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
class MobileConnectionSelector extends React.PureComponent<Props, {}> {
|
||||
private handleConnectionChange = (event: SelectChangeEvent<string>) => {
|
||||
const connectionId = event.target.value
|
||||
this.props.actions.selectConnection(connectionId)
|
||||
}
|
||||
|
||||
private handleCreateConnection = () => {
|
||||
this.props.actions.createConnection()
|
||||
}
|
||||
|
||||
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
|
||||
return connection.name || connection.host || 'Unnamed Connection'
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes, connections, currentConnectionId, isConnected, currentActiveConnectionId } = this.props
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<Select
|
||||
className={classes.select}
|
||||
value={currentConnectionId || ''}
|
||||
onChange={this.handleConnectionChange}
|
||||
aria-label="Select MQTT connection"
|
||||
displayEmpty
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: '60vh',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{connections.map(conn => {
|
||||
// Show "(Connected)" only when:
|
||||
// 1. This connection is selected in the UI (currentConnectionId)
|
||||
// 2. There's an active MQTT connection (isConnected)
|
||||
// 3. The active connection matches this connection (currentActiveConnectionId)
|
||||
// This prevents showing "Connected" when a connection is selected but not connected,
|
||||
// or when switching between connections during a disconnect/reconnect cycle.
|
||||
const showConnectedStatus =
|
||||
conn.id === currentConnectionId && isConnected && conn.id === currentActiveConnectionId
|
||||
const displayName = this.getConnectionDisplayName(conn)
|
||||
return (
|
||||
<MenuItem key={conn.id} value={conn.id}>
|
||||
{displayName}
|
||||
{showConnectedStatus && ' (Connected)'}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
<IconButton
|
||||
className={classes.addButton}
|
||||
onClick={this.handleCreateConnection}
|
||||
aria-label="Create new connection"
|
||||
size="medium"
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const connectionManager = state.connectionManager
|
||||
const connections =
|
||||
connectionManager && connectionManager.connections
|
||||
? Object.values(connectionManager.connections).map(conn => ({
|
||||
id: conn.id,
|
||||
name: conn.name,
|
||||
host: conn.host,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
connections,
|
||||
currentConnectionId: state.connectionManager?.selected,
|
||||
isConnected: state.connection.connected,
|
||||
currentActiveConnectionId: state.connection.connectionId,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
// Using 'as any' here is consistent with other Material-UI + Redux connected components
|
||||
// in this codebase (see ConnectionSettings.tsx, ProfileList/index.tsx, ChartPanel/index.tsx)
|
||||
// to work around complex TypeScript type inference issues with the withStyles + connect HOCs
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(MobileConnectionSelector) as any)
|
||||
@@ -3,25 +3,10 @@ import { FormControlLabel, Switch } from '@mui/material'
|
||||
|
||||
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
|
||||
const { classes, value, toggle, label } = props
|
||||
const toggleSwitch = (
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={toggle}
|
||||
color="primary"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
inputProps={{
|
||||
'aria-label': label
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const toggleSwitch = <Switch checked={value} onChange={toggle} color="primary" />
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel
|
||||
control={toggleSwitch}
|
||||
label={`${label} (${value ? 'On' : 'Off'})`}
|
||||
labelPlacement="bottom"
|
||||
/>
|
||||
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ import { connect } from 'react-redux'
|
||||
import { List } from 'immutable'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useResizeDetector } from 'react-resize-detector'
|
||||
import MobileTabs from './MobileTabs'
|
||||
import PublishTab from '../Sidebar/PublishTab'
|
||||
import { setMobileTab } from '../../actions/Global'
|
||||
import { Dispatch } from 'redux'
|
||||
|
||||
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
|
||||
const ReactSplitPane = ReactSplitPaneImport as any
|
||||
@@ -21,32 +17,14 @@ interface Props {
|
||||
paneDefaults: any
|
||||
connectionId?: string
|
||||
chartPanelItems: List<ChartParameters>
|
||||
mobileTab: number
|
||||
dispatch: Dispatch<any>
|
||||
}
|
||||
|
||||
function ContentView(props: Props) {
|
||||
// Use different defaults for mobile viewports (<=768px width)
|
||||
// Use state for mobile detection that updates on resize
|
||||
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
|
||||
const [height, setHeight] = React.useState<string | number>('100%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
|
||||
const [detectedHeight, setDetectedHeight] = React.useState(0)
|
||||
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
|
||||
|
||||
// Update mobile state on resize
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768)
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
handleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
|
||||
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
|
||||
|
||||
@@ -90,82 +68,6 @@ function ContentView(props: Props) {
|
||||
}
|
||||
}, [props.chartPanelItems])
|
||||
|
||||
// Mobile view with tab switcher
|
||||
if (isMobile) {
|
||||
|
||||
const mobileContainerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 64px)', // Full viewport minus titlebar
|
||||
width: '100%',
|
||||
}
|
||||
|
||||
const tabContentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0, // Critical for flex children with overflow
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}
|
||||
|
||||
// Tree container needs explicit height for the Tree component's height: 100% to work
|
||||
const treeContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
|
||||
const sidebarContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={mobileContainerStyle}>
|
||||
<MobileTabs value={props.mobileTab} onChange={(tab) => props.dispatch(setMobileTab(tab))} />
|
||||
<div style={tabContentStyle}>
|
||||
{/* Topics tab */}
|
||||
{props.mobileTab === 0 && (
|
||||
<div style={treeContainerStyle}>
|
||||
<Tree />
|
||||
</div>
|
||||
)}
|
||||
{/* Details tab */}
|
||||
{props.mobileTab === 1 && (
|
||||
<div style={sidebarContainerStyle}>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
)}
|
||||
{/* Publish tab */}
|
||||
{props.mobileTab === 2 && (
|
||||
<div style={sidebarContainerStyle}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</div>
|
||||
)}
|
||||
{/* Charts tab */}
|
||||
{props.mobileTab === 3 && (
|
||||
<div style={sidebarContainerStyle}>
|
||||
<ChartPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop view with split panes
|
||||
return (
|
||||
<div className={props.paneDefaults}>
|
||||
<span>
|
||||
@@ -207,12 +109,7 @@ function ContentView(props: Props) {
|
||||
<div ref={widthRef} style={{ height: '100%' }}>
|
||||
<div
|
||||
className={props.paneDefaults}
|
||||
style={{
|
||||
minWidth: '250px',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
@@ -226,7 +123,6 @@ function ContentView(props: Props) {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
chartPanelItems: state.charts.get('charts'),
|
||||
mobileTab: state.globalState.get('mobileTab'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Tabs, Tab, Box } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||
import InfoIcon from '@mui/icons-material/Info'
|
||||
import SendIcon from '@mui/icons-material/Send'
|
||||
import ShowChartIcon from '@mui/icons-material/ShowChart'
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
function MobileTabs(props: Props) {
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
props.onChange(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
|
||||
<Tabs
|
||||
value={props.value}
|
||||
onChange={handleChange}
|
||||
variant="fullWidth"
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
aria-label="Topics, Details, Publish and Charts tabs"
|
||||
>
|
||||
<Tab
|
||||
icon={<AccountTreeIcon />}
|
||||
label="Topics"
|
||||
data-testid="mobile-tab-topics"
|
||||
aria-label="View topics tree"
|
||||
id="mobile-tab-0"
|
||||
aria-controls="mobile-tabpanel-0"
|
||||
/>
|
||||
<Tab
|
||||
icon={<InfoIcon />}
|
||||
label="Details"
|
||||
data-testid="mobile-tab-details"
|
||||
aria-label="View topic details"
|
||||
id="mobile-tab-1"
|
||||
aria-controls="mobile-tabpanel-1"
|
||||
/>
|
||||
<Tab
|
||||
icon={<SendIcon />}
|
||||
label="Publish"
|
||||
data-testid="mobile-tab-publish"
|
||||
aria-label="Publish messages"
|
||||
id="mobile-tab-2"
|
||||
aria-controls="mobile-tabpanel-2"
|
||||
/>
|
||||
<Tab
|
||||
icon={<ShowChartIcon />}
|
||||
label="Charts"
|
||||
data-testid="mobile-tab-charts"
|
||||
aria-label="View charts"
|
||||
id="mobile-tab-3"
|
||||
aria-controls="mobile-tabpanel-3"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
root: {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
position: 'relative' as 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: '56px', // Touch-friendly tab height
|
||||
'& .MuiTab-root': {
|
||||
minHeight: '56px', // 48px minimum + padding
|
||||
fontSize: '16px', // Prevent iOS zoom
|
||||
fontWeight: 500,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
textTransform: 'none' as 'none', // Better readability
|
||||
'&:active': {
|
||||
opacity: 0.7, // Touch feedback
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
height: '3px', // Thicker indicator for better visibility
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(MobileTabs)
|
||||
@@ -5,7 +5,7 @@ import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { InputBase } from '@mui/material'
|
||||
import { settingsActions, globalActions } from '../../actions'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { alpha as fade, Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
@@ -17,20 +17,13 @@ function SearchBar(props: {
|
||||
hasConnection: boolean
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
global: typeof globalActions
|
||||
}
|
||||
}) {
|
||||
const { actions, classes, hasConnection, topicFilter } = props
|
||||
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>()
|
||||
const onFocus = useCallback(() => {
|
||||
setHasFocus(true)
|
||||
// On mobile, switch to Topics tab when search is focused
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
|
||||
actions.global.setMobileTab(0)
|
||||
}
|
||||
}, [actions])
|
||||
const onFocus = useCallback(() => setHasFocus(true), [])
|
||||
const onBlur = useCallback(() => setHasFocus(false), [])
|
||||
|
||||
const clearFilter = useCallback(() => {
|
||||
@@ -64,8 +57,8 @@ function SearchBar(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={classes.search} role="search">
|
||||
<div className={classes.searchIcon} aria-hidden="true">
|
||||
<div className={classes.search}>
|
||||
<div className={classes.searchIcon}>
|
||||
<Search />
|
||||
</div>
|
||||
<InputBase
|
||||
@@ -74,7 +67,6 @@ function SearchBar(props: {
|
||||
onFocus,
|
||||
onBlur,
|
||||
ref: inputRef,
|
||||
'aria-label': 'Search topics',
|
||||
}}
|
||||
onChange={onFilterChange}
|
||||
placeholder="Search…"
|
||||
@@ -100,7 +92,6 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
global: bindActionCreators(globalActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -139,37 +130,16 @@ const styles = (theme: Theme) => ({
|
||||
justifyContent: 'center' as 'center',
|
||||
},
|
||||
inputRoot: {
|
||||
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity
|
||||
color: 'inherit' as 'inherit',
|
||||
width: '100%',
|
||||
'& input': {
|
||||
color: `${theme.palette.common.white} !important`, // Target input element directly
|
||||
},
|
||||
},
|
||||
inputInput: {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: `${theme.spacing(6)} !important`, // Ensure padding is applied (48px)
|
||||
paddingLeft: theme.spacing(6),
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
color: `${theme.palette.common.white} !important`, // High contrast white text with priority
|
||||
fontSize: '16px', // Prevent iOS zoom on focus
|
||||
'&::placeholder': {
|
||||
color: `${fade(theme.palette.common.white, 0.7)} !important`, // Semi-transparent white placeholder
|
||||
opacity: 1,
|
||||
},
|
||||
'&::-webkit-input-placeholder': {
|
||||
color: `${fade(theme.palette.common.white, 0.7)} !important`,
|
||||
},
|
||||
'&::-moz-placeholder': {
|
||||
color: `${fade(theme.palette.common.white, 0.7)} !important`,
|
||||
},
|
||||
// Improve mobile input handling
|
||||
[theme.breakpoints.down('md')]: {
|
||||
fontSize: '16px', // Prevent zoom
|
||||
WebkitAppearance: 'none',
|
||||
touchAction: 'manipulation',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ const styles = (theme: Theme) => ({
|
||||
[theme.breakpoints.up(750)]: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
disconnectIcon: {
|
||||
@@ -40,17 +37,9 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
disconnect: {
|
||||
margin: 'auto 8px auto auto',
|
||||
// Hide on mobile (<=768px)
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
margin: 'auto 0 auto 8px',
|
||||
// Hide on mobile (<=768px)
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
disconnectLabel: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* LoginDialog Security Tests
|
||||
*
|
||||
* Security-focused tests for the Login Page:
|
||||
* - Error message visibility to users
|
||||
* - Rate limiting enforcement (anti-brute force)
|
||||
* - Credential requirement validation
|
||||
* - Information disclosure prevention
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { expect } from 'chai'
|
||||
import { describe, it } from 'mocha'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
import { renderWithProviders, waitFor } from '../utils/spec/testUtils'
|
||||
|
||||
// Helper to get elements
|
||||
const getByText = (text: string) => {
|
||||
const elements = Array.from(document.querySelectorAll('*'))
|
||||
return elements.find(el => el.textContent?.includes(text))
|
||||
}
|
||||
const getByTestId = (testId: string) => document.querySelector(`[data-testid="${testId}"]`)
|
||||
|
||||
describe('LoginDialog Security Tests', () => {
|
||||
describe('Error Message Visibility (Security)', () => {
|
||||
it('should display "Invalid credentials" error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error is visible to user
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement).to.exist
|
||||
})
|
||||
|
||||
it('should display rate limiting error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify rate limiting error is visible to user
|
||||
expect(getByText('Too many failed authentication attempts')).to.exist
|
||||
})
|
||||
|
||||
it('should display "Authentication required" error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Please enter your username and password.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify auth required message is visible to user
|
||||
expect(getByText('Please enter your username and password.')).to.exist
|
||||
})
|
||||
|
||||
it('should display generic authentication failure message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Authentication failed. Please try again.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify generic error is visible to user
|
||||
expect(getByText('Authentication failed. Please try again.')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rate Limiting Enforcement (Anti-Brute Force)', () => {
|
||||
it('should disable login button during rate limit countdown', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify button is disabled to prevent further attempts
|
||||
const buttons = Array.from(document.querySelectorAll('button'))
|
||||
const loginButton = buttons.find(b => b.textContent?.match(/Wait \d+s/))
|
||||
expect(loginButton).to.exist
|
||||
expect(loginButton?.hasAttribute('disabled')).to.be.true
|
||||
})
|
||||
|
||||
it('should disable input fields during rate limit countdown', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify inputs are disabled to prevent modification during lockout
|
||||
const usernameInput = getByTestId('username-input')?.querySelector('input')
|
||||
const passwordInput = getByTestId('password-input')?.querySelector('input')
|
||||
|
||||
expect(usernameInput?.hasAttribute('disabled')).to.be.true
|
||||
expect(passwordInput?.hasAttribute('disabled')).to.be.true
|
||||
})
|
||||
|
||||
it('should display countdown timer to user during rate limiting', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify countdown is visible to inform user of lockout duration
|
||||
const countdownElement = getByText('Please wait')
|
||||
expect(countdownElement).to.exist
|
||||
expect(countdownElement?.textContent).to.match(/Please wait \d+ seconds before trying again/i)
|
||||
})
|
||||
|
||||
it('should display both rate limit error and countdown to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify both error and countdown are visible
|
||||
expect(getByText(errorMessage)).to.exist
|
||||
expect(getByText('Please wait 30 seconds before trying again')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Credential Requirement Validation (Prevent Unauthorized Access)', () => {
|
||||
it('should require both username and password fields to be present', () => {
|
||||
const mockLogin = () => {}
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify both credential fields exist and are required
|
||||
const usernameInput = getByTestId('username-input')
|
||||
const passwordInput = getByTestId('password-input')
|
||||
|
||||
expect(usernameInput).to.exist
|
||||
expect(passwordInput).to.exist
|
||||
})
|
||||
|
||||
it('should require password field to be masked', () => {
|
||||
const mockLogin = () => {}
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify password is masked (type="password") for security
|
||||
const passwordInput = getByTestId('password-input')?.querySelector('input')
|
||||
expect(passwordInput?.getAttribute('type')).to.equal('password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Information Disclosure Prevention', () => {
|
||||
it('should use generic "Invalid credentials" error (no username enumeration)', () => {
|
||||
const mockLogin = () => {}
|
||||
// Error doesn't distinguish between invalid username vs invalid password
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error doesn't leak whether username or password was wrong
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement).to.exist
|
||||
expect(errorElement?.textContent).to.not.include('username')
|
||||
expect(errorElement?.textContent).to.not.include('password')
|
||||
})
|
||||
|
||||
it('should not display sensitive information in error messages', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error doesn't contain sensitive data
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement?.textContent).to.not.include('database')
|
||||
expect(errorElement?.textContent).to.not.include('server')
|
||||
expect(errorElement?.textContent).to.not.include('SQL')
|
||||
expect(errorElement?.textContent).to.not.include('error code')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,22 +2,17 @@ import * as React from 'react'
|
||||
import BooleanSwitch from './BooleanSwitch'
|
||||
import BrokerStatistics from './BrokerStatistics'
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight'
|
||||
import CloudOff from '@mui/icons-material/CloudOff'
|
||||
import Logout from '@mui/icons-material/Logout'
|
||||
import TimeLocale from './TimeLocale'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions, connectionActions } from '../../actions'
|
||||
import { globalActions, settingsActions } from '../../actions'
|
||||
import { shell } from 'electron'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicOrder } from '../../reducers/Settings'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
@@ -80,26 +75,12 @@ const styles = (theme: Theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
cursor: 'pointer' as 'pointer',
|
||||
},
|
||||
mobileButtons: {
|
||||
padding: theme.spacing(1),
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as 'column',
|
||||
gap: theme.spacing(1),
|
||||
// Only show on mobile
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
mobileButton: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
global: typeof globalActions
|
||||
connection: typeof connectionActions
|
||||
}
|
||||
autoExpandLimit: number
|
||||
classes: any
|
||||
@@ -238,7 +219,6 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
</Typography>
|
||||
<Divider style={{ userSelect: 'none' }} />
|
||||
</div>
|
||||
<MobileActionButtons classes={classes} actions={actions} />
|
||||
<div>
|
||||
{this.renderAutoExpand()}
|
||||
{this.renderNodeOrder()}
|
||||
@@ -258,52 +238,6 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile action buttons component (disconnect/logout)
|
||||
function MobileActionButtons({ classes, actions }: { classes: any; actions: any }) {
|
||||
const { authDisabled } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Disconnect first
|
||||
actions.connection.disconnect()
|
||||
|
||||
// Clear credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Reload page to reset all state and show login dialog
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.mobileButtons}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudOff />}
|
||||
onClick={actions.connection.disconnect}
|
||||
className={classes.mobileButton}
|
||||
data-testid="mobile-disconnect-button"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
{isBrowserMode && !authDisabled && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Logout />}
|
||||
onClick={handleLogout}
|
||||
className={classes.mobileButton}
|
||||
data-testid="mobile-logout-button"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
autoExpandLimit: state.settings.get('autoExpandLimit'),
|
||||
@@ -320,7 +254,6 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
global: bindActionCreators(globalActions, dispatch),
|
||||
connection: bindActionCreators(connectionActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -1,24 +1,28 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import NodeStats from './NodeStats'
|
||||
import ValuePanel from './ValueRenderer/ValuePanel'
|
||||
const ValuePanelAny = ValuePanel as any
|
||||
import { AppState } from '../../reducers'
|
||||
import { AccordionDetails } from '@mui/material'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions, sidebarActions } from '../../actions'
|
||||
import { settingsActions, sidebarActions } from '../../actions'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import TopicPanel from './TopicPanel/TopicPanel'
|
||||
import Panel from './Panel'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
|
||||
import DetailsTab from './DetailsTab'
|
||||
import PublishTab from './PublishTab'
|
||||
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
const Publish = React.lazy(() => import('./Publish/Publish'))
|
||||
|
||||
interface Props {
|
||||
nodePath?: string
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
actions: typeof sidebarActions
|
||||
globalActions: typeof globalActions
|
||||
settingsActions: typeof settingsActions
|
||||
classes: any
|
||||
connectionId?: string
|
||||
@@ -45,55 +49,27 @@ function useUpdateNodeWhenNodeReceivesUpdates(node?: q.TreeNode<any>) {
|
||||
}, [node])
|
||||
}
|
||||
|
||||
function SidebarNew(props: Props) {
|
||||
function Sidebar(props: Props) {
|
||||
const { classes, tree, nodePath } = props
|
||||
const node = usePollingToFetchTreeNode(tree, nodePath || '')
|
||||
useUpdateNodeWhenNodeReceivesUpdates(node)
|
||||
const [tabValue, setTabValue] = useState(0)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue)
|
||||
}
|
||||
|
||||
// On mobile, don't show tabs (mobile already has Topics/Details tabs at app level)
|
||||
// Just show the content directly
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div id="Sidebar" className={classes.root}>
|
||||
<Box className={classes.mobileContent}>
|
||||
<DetailsTab node={node} />
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: show tabs for Details/Publish
|
||||
return (
|
||||
<div id="Sidebar" className={classes.root}>
|
||||
<Box className={classes.tabsContainer}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
className={classes.tabs}
|
||||
>
|
||||
<Tab label="Details" className={classes.tab} />
|
||||
<Tab label="Publish" className={classes.tab} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box className={classes.tabContent}>
|
||||
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
|
||||
<DetailsTab node={node} />
|
||||
</Box>
|
||||
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</Box>
|
||||
</Box>
|
||||
<div id="Sidebar" className={classes.drawer}>
|
||||
<div>
|
||||
<TopicPanel node={node} />
|
||||
<ValuePanelAny lastUpdate={node ? node.lastUpdate : 0} />
|
||||
<Panel>
|
||||
<span>Publish</span>
|
||||
<Publish connectionId={props.connectionId} />
|
||||
</Panel>
|
||||
<Panel detailsHidden={!node}>
|
||||
<span>Stats</span>
|
||||
<AccordionDetails className={classes.details}>
|
||||
<NodeStats node={node} />
|
||||
</AccordionDetails>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -109,44 +85,18 @@ const mapStateToProps = (state: AppState) => {
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(sidebarActions, dispatch),
|
||||
globalActions: bindActionCreators(globalActions, dispatch),
|
||||
settingsActions: bindActionCreators(settingsActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
drawer: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
tabsContainer: {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
tabs: {
|
||||
minHeight: '48px',
|
||||
},
|
||||
tab: {
|
||||
minHeight: '48px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
textTransform: 'none' as 'none',
|
||||
padding: theme.spacing(1.5, 2),
|
||||
},
|
||||
tabContent: {
|
||||
flex: 1,
|
||||
overflowY: 'auto' as 'auto',
|
||||
overflowX: 'hidden' as 'hidden',
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
mobileContent: {
|
||||
flex: 1,
|
||||
overflowY: 'auto' as 'auto',
|
||||
overflowX: 'hidden' as 'hidden',
|
||||
padding: theme.spacing(2),
|
||||
details: {
|
||||
padding: '0px 16px 8px 8px',
|
||||
display: 'block',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(SidebarNew))
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Sidebar))
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import { Link } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { treeActions } from '../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode<any>
|
||||
classes: any
|
||||
actions: typeof treeActions
|
||||
}
|
||||
|
||||
function SimpleBreadcrumb(props: Props) {
|
||||
const { node, classes, actions } = props
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const branch = node.branch()
|
||||
const breadcrumbNodes = branch
|
||||
.map(n => n.sourceEdge)
|
||||
.filter(edge => Boolean(edge) && edge?.target)
|
||||
.map(edge => ({ name: edge?.name || '', target: edge!.target }))
|
||||
.filter(item => item.name !== '')
|
||||
|
||||
if (breadcrumbNodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.breadcrumbContainer}>
|
||||
{breadcrumbNodes.map((item, index) => (
|
||||
<span key={item.target.hash()}>
|
||||
{index > 0 && <span className={classes.separator}> / </span>}
|
||||
<Link
|
||||
component="button"
|
||||
variant="h6"
|
||||
className={classes.breadcrumbLink}
|
||||
onClick={() => actions.selectTopic(item.target)}
|
||||
underline="hover"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
breadcrumbContainer: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
},
|
||||
breadcrumbLink: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.primary,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left' as 'left',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
padding: 0,
|
||||
lineHeight: 1.5,
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
color: theme.palette.text.secondary,
|
||||
userSelect: 'none' as 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(treeActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(withStyles(styles)(SimpleBreadcrumb))
|
||||
@@ -24,7 +24,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
|
||||
const selectOption = useCallback(
|
||||
(decoder: MessageDecoder, format: string) => {
|
||||
if (!node || !node.viewModel) {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
|
||||
return (
|
||||
<Button onClick={handleToggle}>
|
||||
{props.node?.viewModel?.decoder?.format ?? props.node?.type}
|
||||
{props.node?.viewModel.decoder?.format ?? props.node?.type}
|
||||
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
|
||||
@@ -36,17 +36,15 @@ function ActionButtons(props: {
|
||||
>
|
||||
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
|
||||
<Tooltip title="Show difference between the current and the last message">
|
||||
<span className={props.classes.buttonContent}>
|
||||
<span>
|
||||
<Code className={props.classes.toggleButtonIcon} />
|
||||
<span className={props.classes.buttonText}>Diff</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton className={props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
|
||||
<Tooltip title="Raw / formatted JSON / formatted sparkplugb protojson">
|
||||
<span className={props.classes.buttonContent}>
|
||||
<span>
|
||||
<Reorder className={props.classes.toggleButtonIcon} />
|
||||
<span className={props.classes.buttonText}>Raw</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
@@ -57,20 +55,9 @@ function ActionButtons(props: {
|
||||
const styles = (theme: Theme) => ({
|
||||
toggleButton: {
|
||||
height: '36px',
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
},
|
||||
toggleButtonIcon: {
|
||||
verticalAlign: 'middle',
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
buttonContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: '0.875rem',
|
||||
textTransform: 'none' as 'none',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -52,21 +52,8 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
|
||||
return null
|
||||
}
|
||||
|
||||
// On mobile, the expand button has its own click handler separate from topic selection
|
||||
// On desktop, clicking anywhere (including expander) selects and toggles via didClickTitle
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
|
||||
const onClick = isMobile ? props.toggleCollapsed : undefined
|
||||
|
||||
return (
|
||||
<span
|
||||
key="expander"
|
||||
className={props.classes.expander}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
aria-label={props.collapsed ? 'Expand topic' : 'Collapse topic'}
|
||||
aria-expanded={!props.collapsed}
|
||||
tabIndex={isMobile ? 0 : -1}
|
||||
>
|
||||
<span key="expander" className={props.classes.expander} onClick={props.toggleCollapsed}>
|
||||
{props.collapsed ? '▶' : '▼'}
|
||||
</span>
|
||||
)
|
||||
@@ -96,39 +83,27 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
|
||||
|
||||
return {
|
||||
value: {
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
padding: '0',
|
||||
fontSize: isMobile ? '15px' : 'inherit', // Slightly larger on mobile
|
||||
},
|
||||
sourceEdge: {
|
||||
fontWeight: 'bold' as 'bold',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
fontSize: isMobile ? '16px' : 'inherit', // Base 16px on mobile to prevent zoom
|
||||
},
|
||||
expander: {
|
||||
color: theme.palette.mode === 'light' ? '#222' : '#eee',
|
||||
cursor: 'pointer' as 'pointer',
|
||||
paddingRight: isMobile ? theme.spacing(1) : theme.spacing(0.25), // Larger touch area
|
||||
paddingLeft: isMobile ? theme.spacing(0.5) : 0,
|
||||
minWidth: isMobile ? '32px' : 'auto', // 40px total width on mobile for touch
|
||||
display: 'inline-block' as 'inline-block',
|
||||
textAlign: 'center' as 'center',
|
||||
userSelect: 'none' as 'none',
|
||||
fontSize: isMobile ? '18px' : 'inherit', // Larger icon on mobile
|
||||
},
|
||||
collapsedSubnodes: {
|
||||
color: theme.palette.text.secondary,
|
||||
userSelect: 'none' as 'none',
|
||||
fontSize: isMobile ? '14px' : 'inherit',
|
||||
},
|
||||
}
|
||||
}
|
||||
const styles = (theme: Theme) => ({
|
||||
value: {
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
padding: '0',
|
||||
},
|
||||
sourceEdge: {
|
||||
fontWeight: 'bold' as 'bold',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
expander: {
|
||||
color: theme.palette.mode === 'light' ? '#222' : '#eee',
|
||||
cursor: 'pointer' as 'pointer',
|
||||
paddingRight: theme.spacing(0.25),
|
||||
userSelect: 'none' as 'none',
|
||||
},
|
||||
collapsedSubnodes: {
|
||||
color: theme.palette.text.secondary,
|
||||
userSelect: 'none' as 'none',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(memo(TreeNodeTitle))
|
||||
|
||||
@@ -61,22 +61,10 @@ function TreeNodeComponent(props: Props) {
|
||||
const didClickTitle = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
|
||||
|
||||
if (isMobile) {
|
||||
// Mobile: Only select the topic (no toggle)
|
||||
// Expanding is handled by the separate expand button click
|
||||
didSelectTopic()
|
||||
// Switch to details tab on mobile after selecting a topic
|
||||
actions.setMobileTab(1)
|
||||
} else {
|
||||
// Desktop: Original behavior - select AND toggle (click anywhere works)
|
||||
didSelectTopic()
|
||||
setCollapsedOverride(!isCollapsed)
|
||||
}
|
||||
didSelectTopic()
|
||||
setCollapsedOverride(!isCollapsed)
|
||||
},
|
||||
[isCollapsed, didSelectTopic, actions]
|
||||
[isCollapsed, didSelectTopic]
|
||||
)
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
@@ -134,10 +122,6 @@ function TreeNodeComponent(props: Props) {
|
||||
onClick={didClickTitle}
|
||||
tabIndex={-1}
|
||||
onKeyDown={deleteTopicCallback}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={`Topic: ${name || treeNode.sourceEdge?.name || 'root'}`}
|
||||
>
|
||||
<TreeNodeTitle
|
||||
lastUpdate={treeNode.lastUpdate}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { blueGrey } from '@mui/material/colors'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
export const styles = (theme: Theme) => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
|
||||
|
||||
return {
|
||||
animationLight: {
|
||||
willChange: 'auto',
|
||||
@@ -27,7 +25,7 @@ export const styles = (theme: Theme) => {
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
padding: isMobile ? '1px 0px' : '1px 0px 0px 0px',
|
||||
padding: '1px 0px 0px 0px',
|
||||
},
|
||||
topicSelect: {
|
||||
float: 'right' as 'right',
|
||||
@@ -36,7 +34,7 @@ export const styles = (theme: Theme) => {
|
||||
marginTop: '-1px',
|
||||
},
|
||||
subnodes: {
|
||||
marginLeft: isMobile ? theme.spacing(2) : theme.spacing(1.5), // Increased indentation on mobile
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: (theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
|
||||
@@ -44,23 +42,15 @@ export const styles = (theme: Theme) => {
|
||||
hover: {},
|
||||
title: {
|
||||
borderRadius: '4px',
|
||||
lineHeight: isMobile ? '1.3em' : '1em',
|
||||
lineHeight: '1em',
|
||||
display: 'inline-block' as 'inline-block',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
minHeight: isMobile ? '40px' : '14px', // 44px touch target on mobile (WCAG AA minimum)
|
||||
height: 'auto' as 'auto',
|
||||
padding: isMobile ? '8px 8px' : '1px 4px 0 4px', // Reduced padding, still touch-friendly
|
||||
height: '14px',
|
||||
padding: '1px 4px 0 4px',
|
||||
margin: '1px 0px',
|
||||
fontSize: isMobile ? '16px' : 'inherit', // Prevent iOS zoom on focus
|
||||
cursor: 'pointer' as 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.mode === 'light' ? blueGrey[100] : theme.palette.primary.light,
|
||||
},
|
||||
// Better touch feedback on mobile
|
||||
[theme.breakpoints.down('md')]: {
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
touchAction: 'manipulation',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { withStyles } from '@mui/styles'
|
||||
import { updateNotifierActions } from '../actions'
|
||||
|
||||
import { Button, IconButton, Modal, Paper, Snackbar, SnackbarContent, Typography } from '@mui/material'
|
||||
import { rendererRpc, getAppVersion } from '../eventBus'
|
||||
import { rendererRpc, getAppVersion } from '../../../events'
|
||||
|
||||
interface Props {
|
||||
showUpdateNotification: boolean
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
*/
|
||||
function ClearAdornment(props: Props) {
|
||||
const theme = useTheme()
|
||||
|
||||
|
||||
if (!props.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import FileCopy from '@mui/icons-material/FileCopy'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions } from '../../actions'
|
||||
import copyTextFallback from 'copy-text-to-clipboard'
|
||||
|
||||
// Fallback for older browsers or when clipboard API is not available
|
||||
const copyTextFallback = require('copy-text-to-clipboard')
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -17,7 +19,7 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed, using fallback:', error)
|
||||
}
|
||||
|
||||
|
||||
// Fallback to copy-text-to-clipboard library
|
||||
return copyTextFallback(text)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ class CustomIconButton extends React.PureComponent<Props, {}> {
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<IconButton
|
||||
className={this.props.classes.button}
|
||||
style={this.props.style}
|
||||
<IconButton
|
||||
className={this.props.classes.button}
|
||||
style={this.props.style}
|
||||
onClick={this.onClick}
|
||||
data-testid={this.props['data-testid']}
|
||||
>
|
||||
|
||||
@@ -5,58 +5,16 @@ import CustomIconButton from './CustomIconButton'
|
||||
|
||||
import { SaveAlt } from '@mui/icons-material'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { rendererRpc, writeToFile } from '../../eventBus'
|
||||
import { rendererRpc, writeToFile } from '../../../../events'
|
||||
import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
import { globalActions } from '../../actions'
|
||||
|
||||
/**
|
||||
* Download a file in browser mode using blob URL
|
||||
* @param data Base64-encoded file data
|
||||
* @param filename Filename for the download
|
||||
* @returns The filename that was downloaded
|
||||
*/
|
||||
function downloadFileInBrowser(data: string, filename: string): string {
|
||||
// Decode base64 data
|
||||
const binaryString = atob(data)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([bytes], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
export async function saveToFile(data: string): Promise<string | undefined> {
|
||||
const rejectReasons = {
|
||||
errorWritingFile: 'Error writing file',
|
||||
}
|
||||
|
||||
// In browser mode, use browser download
|
||||
if (isBrowserMode) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `mqtt-message-${timestamp}.bin`
|
||||
try {
|
||||
downloadFileInBrowser(data, filename)
|
||||
return filename
|
||||
} catch (error) {
|
||||
throw rejectReasons.errorWritingFile
|
||||
}
|
||||
}
|
||||
|
||||
// In Electron mode, use native file dialog
|
||||
const { canceled, filePath } = await rendererRpc.call(makeSaveDialogRpc(), {
|
||||
securityScopedBookmarks: true,
|
||||
})
|
||||
@@ -109,7 +67,7 @@ class Save extends React.PureComponent<Props, State> {
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Save to file" data-testid="save-button">
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Save to file">
|
||||
<div style={{ marginTop: '2px' }}>{icon}</div>
|
||||
</CustomIconButton>
|
||||
)
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Event bus abstraction layer
|
||||
* Provides the correct rendererRpc and rendererEvents implementation based on runtime environment
|
||||
* - In browser mode: uses Socket.IO-based event bus
|
||||
* - In Electron mode: uses IPC-based event bus
|
||||
*
|
||||
* This module uses dynamic imports to avoid bundling unused dependencies.
|
||||
*/
|
||||
|
||||
import { isBrowserMode } from './utils/browserMode'
|
||||
import type { Rpc } from '../../events/EventSystem/Rpc'
|
||||
import type { EventBusInterface } from '../../events/EventSystem/EventBusInterface'
|
||||
|
||||
let rendererRpcInstance: Rpc<any> | null = null
|
||||
let rendererEventsInstance: EventBusInterface | null = null
|
||||
let backendRpcInstance: Rpc<any> | null = null
|
||||
let backendEventsInstance: EventBusInterface | null = null
|
||||
|
||||
/**
|
||||
* Get the renderer RPC instance
|
||||
* Lazy-loads the appropriate implementation based on environment
|
||||
*/
|
||||
export function getRendererRpc(): Rpc<any> {
|
||||
if (rendererRpcInstance) {
|
||||
return rendererRpcInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// Dynamic import for browser mode
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
rendererRpcInstance = browserEventBus.rendererRpc
|
||||
} else {
|
||||
// Dynamic import for Electron mode
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
rendererRpcInstance = electronEventBus.rendererRpc
|
||||
}
|
||||
|
||||
return rendererRpcInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the renderer events instance
|
||||
* Lazy-loads the appropriate implementation based on environment
|
||||
*/
|
||||
export function getRendererEvents(): EventBusInterface {
|
||||
if (rendererEventsInstance) {
|
||||
return rendererEventsInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// Dynamic import for browser mode
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
rendererEventsInstance = browserEventBus.rendererEvents
|
||||
} else {
|
||||
// Dynamic import for Electron mode
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
rendererEventsInstance = electronEventBus.rendererEvents
|
||||
}
|
||||
|
||||
return rendererEventsInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend RPC instance (for compatibility)
|
||||
*/
|
||||
export function getBackendRpc(): Rpc<any> {
|
||||
if (backendRpcInstance) {
|
||||
return backendRpcInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// In browser mode, backend is accessed via socket.io
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
backendRpcInstance = browserEventBus.backendRpc
|
||||
} else {
|
||||
// In Electron mode, backend RPC uses IPC
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
backendRpcInstance = electronEventBus.backendRpc
|
||||
}
|
||||
|
||||
return backendRpcInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend events instance (for compatibility)
|
||||
*/
|
||||
export function getBackendEvents(): EventBusInterface {
|
||||
if (backendEventsInstance) {
|
||||
return backendEventsInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// In browser mode, backend is accessed via socket.io
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
backendEventsInstance = browserEventBus.backendEvents
|
||||
} else {
|
||||
// In Electron mode, backend events use IPC
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
backendEventsInstance = electronEventBus.backendEvents
|
||||
}
|
||||
|
||||
return backendEventsInstance
|
||||
}
|
||||
|
||||
// Export as named constants for convenience (lazy-loaded on first access)
|
||||
export const rendererRpc = new Proxy({} as Rpc<any>, {
|
||||
get(target, prop) {
|
||||
return getRendererRpc()[prop as keyof Rpc<any>]
|
||||
}
|
||||
})
|
||||
|
||||
export const rendererEvents = new Proxy({} as EventBusInterface, {
|
||||
get(target, prop) {
|
||||
return getRendererEvents()[prop as keyof EventBusInterface]
|
||||
}
|
||||
})
|
||||
|
||||
export const backendRpc = new Proxy({} as Rpc<any>, {
|
||||
get(target, prop) {
|
||||
return getBackendRpc()[prop as keyof Rpc<any>]
|
||||
}
|
||||
})
|
||||
|
||||
export const backendEvents = new Proxy({} as EventBusInterface, {
|
||||
get(target, prop) {
|
||||
return getBackendEvents()[prop as keyof EventBusInterface]
|
||||
}
|
||||
})
|
||||
|
||||
// Re-export all event definitions that are shared
|
||||
export * from '../../events/Events'
|
||||
export * from '../../events/EventsV2'
|
||||
export * from '../../events/EventSystem/EventDispatcher'
|
||||
export * from '../../events/EventSystem/EventBusInterface'
|
||||
@@ -2,16 +2,19 @@ import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom/client'
|
||||
import App from './components/App'
|
||||
import Demo from './components/Demo'
|
||||
import { AppState } from './reducers'
|
||||
import reducers, { AppState } from './reducers'
|
||||
import { thunk as reduxThunk } from 'redux-thunk'
|
||||
import { applyMiddleware, compose, createStore } from 'redux'
|
||||
import { batchDispatchMiddleware } from 'redux-batched-actions'
|
||||
import { connect, Provider } from 'react-redux'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { ThemeProvider as LegacyThemeProvider } from '@mui/styles'
|
||||
import './utils/tracking'
|
||||
import { themes } from './theme'
|
||||
import { BrowserAuthWrapper } from './components/BrowserAuthWrapper'
|
||||
import { store } from './store'
|
||||
import './autoConnectHandler' // Initialize auto-connect handling
|
||||
|
||||
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
|
||||
const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
|
||||
|
||||
function ApplicationRenderer(props: { theme: 'light' | 'dark' }) {
|
||||
const theme = props.theme === 'light' ? themes.lightTheme : themes.darkTheme
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Export store singleton for use in other modules
|
||||
import reducers from './reducers'
|
||||
import { thunk as reduxThunk } from 'redux-thunk'
|
||||
import { applyMiddleware, compose, createStore } from 'redux'
|
||||
import { batchDispatchMiddleware } from 'redux-batched-actions'
|
||||
|
||||
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
|
||||
export const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rendererRpc } from '../eventBus'
|
||||
import { rendererRpc } from '../../../events'
|
||||
|
||||
import { storageStoreEvent, storageLoadEvent, storageClearEvent } from '../../../events/StorageEvents'
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './Events'
|
||||
export * from './EventsV2'
|
||||
export * from './EventSystem/EventDispatcher'
|
||||
// EventBus exports removed - this file contains Electron-specific imports
|
||||
// In Electron mode, webpack replaces '../../../events' to use './EventSystem/EventBus'
|
||||
// In browser mode, webpack replaces '../../../events' to use browserEventBus.ts
|
||||
// which should not be loaded in server/browser mode
|
||||
// Electron code should import directly from './EventSystem/EventBus'
|
||||
// export * from './EventSystem/EventBus'
|
||||
export * from './EventSystem/EventBusInterface'
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -16,9 +16,7 @@
|
||||
"test:backend": "(cd backend && yarn test)",
|
||||
"test:electron": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:browser": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:mobile-ui": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:demo-video": "npx tsc && node dist/src/spec/demoVideo.js",
|
||||
"test:demo-video:mobile": "npx tsc && node dist/src/spec/demoVideoMobile.js",
|
||||
"test:ui": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:ui:vnc": "tsc && ./scripts/uiTestsWithVnc.sh",
|
||||
"test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js",
|
||||
@@ -29,12 +27,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 +114,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 +130,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"
|
||||
},
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
/**
|
||||
* Cut video into segments as GIFs based on scenes.json
|
||||
*
|
||||
* This script reads scenes.json and uses ffmpeg to create GIF segments
|
||||
* from the ui-test.mp4 video file.
|
||||
*/
|
||||
# Read scenes.json and cut video into segments as GIFs
|
||||
if [ ! -f "scenes.json" ]; then
|
||||
echo "scenes.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "ui-test.mp4" ]; then
|
||||
echo "ui-test.mp4 not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cutting video into GIF segments based on scenes.json..."
|
||||
|
||||
export GIF_SCALE="1024"
|
||||
|
||||
# Parse scenes.json and cut video segments as GIFs
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Check required files exist
|
||||
if (!fs.existsSync('scenes.json')) {
|
||||
console.error('scenes.json not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync('ui-test.mp4')) {
|
||||
console.error('ui-test.mp4 not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Cutting video into GIF segments based on scenes.json...');
|
||||
|
||||
const scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
|
||||
|
||||
const GIF_SCALE = process.env.GIF_SCALE || '1024';
|
||||
|
||||
console.log('Creating GIF segments...');
|
||||
|
||||
// Sanitize scene name to prevent path traversal and command injection
|
||||
@@ -37,13 +33,13 @@ function sanitizeName(name) {
|
||||
|
||||
async function cutSegmentAsGif(scene, index) {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentName = `segment-${String(index + 1).padStart(2, '0')}-${safeName}`;
|
||||
const paletteFile = `${segmentName}-palette.png`;
|
||||
const outputFile = `${segmentName}.gif`;
|
||||
const segmentName = \`segment-\${String(index + 1).padStart(2, '0')}-\${safeName}\`;
|
||||
const paletteFile = \`\${segmentName}-palette.png\`;
|
||||
const outputFile = \`\${segmentName}.gif\`;
|
||||
const startTime = scene.start / 1000; // Convert ms to seconds
|
||||
const duration = scene.duration / 1000; // Convert ms to seconds
|
||||
|
||||
console.log(`Creating ${outputFile} (start: ${startTime}s, duration: ${duration}s)`);
|
||||
console.log(\`Creating \${outputFile} (start: \${startTime}s, duration: \${duration}s)\`);
|
||||
|
||||
// Step 1: Generate palette for this segment
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -52,7 +48,7 @@ async function cutSegmentAsGif(scene, index) {
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test.mp4',
|
||||
'-vf', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos,palettegen`,
|
||||
'-vf', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos,palettegen',
|
||||
paletteFile
|
||||
]);
|
||||
|
||||
@@ -60,8 +56,8 @@ async function cutSegmentAsGif(scene, index) {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create palette for ${outputFile}`);
|
||||
reject(new Error(`ffmpeg palette generation exited with code ${code}`));
|
||||
console.error(\`Failed to create palette for \${outputFile}\`);
|
||||
reject(new Error(\`ffmpeg palette generation exited with code \${code}\`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,7 +70,7 @@ async function cutSegmentAsGif(scene, index) {
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test.mp4',
|
||||
'-i', paletteFile,
|
||||
'-filter_complex', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
|
||||
'-filter_complex', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos[x];[x][1:v]paletteuse',
|
||||
outputFile
|
||||
]);
|
||||
|
||||
@@ -89,8 +85,8 @@ async function cutSegmentAsGif(scene, index) {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create ${outputFile}`);
|
||||
reject(new Error(`ffmpeg GIF creation exited with code ${code}`));
|
||||
console.error(\`Failed to create \${outputFile}\`);
|
||||
reject(new Error(\`ffmpeg GIF creation exited with code \${code}\`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -100,8 +96,11 @@ async function cutSegmentAsGif(scene, index) {
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
await cutSegmentAsGif(scenes[i], i);
|
||||
}
|
||||
console.log('Video segments created successfully');
|
||||
console.log('All GIF segments created successfully');
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"
|
||||
|
||||
echo "Video segments created successfully"
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Cut mobile demo video into segments as GIFs based on scenes-mobile.json
|
||||
*
|
||||
* This script reads scenes-mobile.json and uses ffmpeg to create GIF segments
|
||||
* from the ui-test-mobile.mp4 video file.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Check required files exist
|
||||
if (!fs.existsSync('scenes-mobile.json')) {
|
||||
console.error('scenes-mobile.json not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync('ui-test-mobile.mp4')) {
|
||||
console.error('ui-test-mobile.mp4 not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Cutting mobile video into GIF segments based on scenes-mobile.json...');
|
||||
|
||||
const scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
|
||||
|
||||
const GIF_SCALE = process.env.GIF_SCALE || '412';
|
||||
|
||||
console.log('Creating mobile GIF segments...');
|
||||
|
||||
// Sanitize scene name to prevent path traversal and command injection
|
||||
function sanitizeName(name) {
|
||||
// Remove any characters that aren't alphanumeric, dash, or underscore
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
|
||||
async function cutSegmentAsGif(scene, index) {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentName = `segment-mobile-${String(index + 1).padStart(2, '0')}-${safeName}`;
|
||||
const paletteFile = `${segmentName}-palette.png`;
|
||||
const outputFile = `${segmentName}.gif`;
|
||||
const startTime = scene.start / 1000; // Convert ms to seconds
|
||||
const duration = scene.duration / 1000; // Convert ms to seconds
|
||||
|
||||
console.log(`Creating ${outputFile} (start: ${startTime}s, duration: ${duration}s)`);
|
||||
|
||||
// Step 1: Generate palette for this segment
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test-mobile.mp4',
|
||||
'-vf', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos,palettegen`,
|
||||
paletteFile
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create palette for ${outputFile}`);
|
||||
reject(new Error(`ffmpeg palette generation exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Create GIF using the palette
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test-mobile.mp4',
|
||||
'-i', paletteFile,
|
||||
'-filter_complex', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
|
||||
outputFile
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
// Clean up palette file
|
||||
try {
|
||||
fs.unlinkSync(paletteFile);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create ${outputFile}`);
|
||||
reject(new Error(`ffmpeg GIF creation exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
await cutSegmentAsGif(scenes[i], i);
|
||||
}
|
||||
console.log('Mobile video segments created successfully');
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
|
||||
// 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 generateMarkdownSummaryMobile.js <base-url> [test-status]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read scenes-mobile.json if it exists
|
||||
let scenes = [];
|
||||
try {
|
||||
if (fs.existsSync('scenes-mobile.json')) {
|
||||
scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Warning: Could not read scenes-mobile.json:', 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 with status indication
|
||||
const statusIcon = testStatus === 'success' ? '✅' : '⚠️';
|
||||
const statusText = testStatus === 'success' ? 'Generated Successfully' : 'Generated (Test Failed)';
|
||||
|
||||
let markdown = `## ${statusIcon} Mobile Demo Video ${statusText}\n\n`;
|
||||
|
||||
if (testStatus !== 'success') {
|
||||
markdown += `> ⚠️ **Note**: The mobile demo test encountered errors but videos were still uploaded for debugging. Check the logs for details.\n\n`;
|
||||
}
|
||||
|
||||
markdown += `### Full Mobile Video (Pixel 6 - 412x914)\n\n`;
|
||||
markdown += `[📥 Download Mobile Video (MP4)](${baseUrl}/ui-test-mobile.mp4) | [GIF](${baseUrl}/ui-test-mobile.gif)\n\n`;
|
||||
markdown += `---\n\n`;
|
||||
|
||||
if (scenes.length > 0) {
|
||||
markdown += `### 📑 Mobile Video Segments\n\n`;
|
||||
markdown += `<details>\n`;
|
||||
markdown += `<summary>Click to expand mobile segments</summary>\n\n`;
|
||||
|
||||
scenes.forEach((scene, index) => {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentFile = `segment-mobile-${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 += `\n\n`;
|
||||
markdown += `</details>\n\n`;
|
||||
});
|
||||
|
||||
markdown += `</details>\n\n`;
|
||||
} else {
|
||||
markdown += `*Scene information not available - check if video processing completed*\n\n`;
|
||||
}
|
||||
|
||||
markdown += `_Mobile videos recorded at 412x914 (Pixel 6 viewport). Videos will expire in 90 days._`;
|
||||
|
||||
console.log(markdown);
|
||||
@@ -29,7 +29,7 @@ mv app720.gif ui-test.gif
|
||||
# Cut video into segments based on scenes.json
|
||||
echo "Cutting video into segments..."
|
||||
if [ -f "scenes.json" ]; then
|
||||
node ./scripts/cutVideoSegments.js
|
||||
./scripts/cutVideoSegments.sh
|
||||
else
|
||||
echo "Warning: scenes.json not found, skipping segment creation"
|
||||
fi
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mobile demo video post-processing script
|
||||
# Converts raw mobile video to MP4 and GIF, then cuts into segments
|
||||
|
||||
DIMENSIONS="412x914"
|
||||
GIF_SCALE="412"
|
||||
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv app2-mobile.mp4
|
||||
|
||||
# The video starts with a few blank frames, we want to know when they stop
|
||||
ffprobe -f lavfi -i "movie=app2-mobile.mp4,blackdetect[out0]" -show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -v quiet > ffmpeg_info_mobile
|
||||
END_OF_BLACK=`cat ffmpeg_info_mobile | grep end | head -n1 | cut -d'=' -f2`
|
||||
|
||||
# Remove grey frames at the beginning (app start and splash screen)
|
||||
END_OF_BLACK=`awk "BEGIN {print $END_OF_BLACK+0.8; exit}"`
|
||||
|
||||
# Trim black frames at start
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -ss $END_OF_BLACK app-mobile.mp4
|
||||
|
||||
# Generate gif palette
|
||||
ffmpeg -y -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -vf "fps=10,scale=$GIF_SCALE:-1:flags=lanczos,palettegen" palette-mobile.png
|
||||
|
||||
# Create gif
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -i palette-mobile.png -ss $END_OF_BLACK -filter_complex "fps=10,scale=$GIF_SCALE:-1:flags=lanczos[x];[x][1:v]paletteuse" app-mobile.gif
|
||||
|
||||
# Clean up
|
||||
rm ffmpeg_info_mobile palette-mobile.png qrawvideorgb24-mobile.yuv app2-mobile.mp4
|
||||
|
||||
mv app-mobile.mp4 ui-test-mobile.mp4
|
||||
mv app-mobile.gif ui-test-mobile.gif
|
||||
|
||||
# Cut video into segments based on scenes-mobile.json
|
||||
echo "Cutting mobile video into segments..."
|
||||
if [ -f "scenes-mobile.json" ]; then
|
||||
node ./scripts/cutVideoSegmentsMobile.js
|
||||
else
|
||||
echo "Warning: scenes-mobile.json not found, skipping segment creation"
|
||||
fi
|
||||
@@ -2,7 +2,7 @@
|
||||
# Browser Mode Test Runner
|
||||
#
|
||||
# This script runs UI tests against the browser mode server (instead of Electron).
|
||||
# It starts a mosquitto MQTT broker automatically and cleans it up on exit.
|
||||
# It expects a mosquitto MQTT broker to be running (via service or manually started).
|
||||
# The broker address is configured via environment variables.
|
||||
#
|
||||
# Environment Variables:
|
||||
@@ -12,7 +12,6 @@
|
||||
# BROWSER_MODE_URL - URL for browser tests (set automatically)
|
||||
# TESTS_MQTT_BROKER_HOST - MQTT broker host for tests (required, default: 127.0.0.1)
|
||||
# TESTS_MQTT_BROKER_PORT - MQTT broker port for tests (default: 1883)
|
||||
# USE_MOBILE_VIEWPORT - Enable mobile viewport (default: false, set to 'true' for mobile tests)
|
||||
#
|
||||
set -e
|
||||
|
||||
@@ -24,21 +23,10 @@ function finish {
|
||||
echo "Stopping server ($PID_SERVER).."
|
||||
kill "$PID_SERVER" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_MOSQUITTO" ]]; then
|
||||
echo "Stopping mosquitto ($PID_MOSQUITTO).."
|
||||
kill "$PID_MOSQUITTO" || echo "Already stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
|
||||
# Start mqtt broker
|
||||
mosquitto &
|
||||
export PID_MOSQUITTO=$!
|
||||
sleep 1
|
||||
npx -y playwright install
|
||||
|
||||
# Set credentials for browser authentication (tests will use these to login)
|
||||
export MQTT_EXPLORER_USERNAME=${MQTT_EXPLORER_USERNAME:-test}
|
||||
export MQTT_EXPLORER_PASSWORD=${MQTT_EXPLORER_PASSWORD:-test123}
|
||||
@@ -66,15 +54,8 @@ done
|
||||
export BROWSER_MODE_URL="http://localhost:${PORT}"
|
||||
export TESTS_MQTT_BROKER_HOST="${TESTS_MQTT_BROKER_HOST:-127.0.0.1}"
|
||||
export TESTS_MQTT_BROKER_PORT="${TESTS_MQTT_BROKER_PORT:-1883}"
|
||||
# Enable mobile viewport for mobile UI tests
|
||||
export USE_MOBILE_VIEWPORT="${USE_MOBILE_VIEWPORT:-false}"
|
||||
|
||||
echo "Using MQTT broker at $TESTS_MQTT_BROKER_HOST:$TESTS_MQTT_BROKER_PORT"
|
||||
if [ "$USE_MOBILE_VIEWPORT" = "true" ]; then
|
||||
echo "Mobile viewport: ENABLED (412x914)"
|
||||
else
|
||||
echo "Mobile viewport: DISABLED (desktop 1280x720)"
|
||||
fi
|
||||
|
||||
yarn test:browser
|
||||
TEST_EXIT_CODE=$?
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/bin/bash
|
||||
function finish {
|
||||
set +e
|
||||
echo "Exiting, cleaning up.."
|
||||
|
||||
echo "Stopping TMUX session (record-mobile).."
|
||||
tmux kill-session -t record-mobile || echo "Already stopped"
|
||||
|
||||
if [[ ! -z "$PID_MOSQUITTO" ]]; then
|
||||
echo "Stopping mosquitto ($PID_MOSQUITTO).."
|
||||
kill "$PID_MOSQUITTO" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_VNC" ]]; then
|
||||
echo "Stopping VNC ($PID_VNC).."
|
||||
kill "$PID_VNC" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_XVFB" ]]; then
|
||||
echo "Stopping XVFB ($PID_XVFB).."
|
||||
kill "$PID_XVFB" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_SERVER" ]]; then
|
||||
echo "Stopping MQTT Explorer server ($PID_SERVER).."
|
||||
kill "$PID_SERVER" || echo "Already stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
set -e
|
||||
|
||||
# Mobile viewport dimensions (Pixel 6 - height must be even for h264)
|
||||
DIMENSIONS="412x914"
|
||||
# Chrome header in --app mode is 88px tall
|
||||
# Add 88px to Xvfb height to accommodate the Chrome header
|
||||
CHROME_HEADER_HEIGHT=88
|
||||
XVFB_HEIGHT=$((914 + CHROME_HEADER_HEIGHT))
|
||||
XVFB_DIMENSIONS="412x${XVFB_HEIGHT}"
|
||||
SCR=99
|
||||
|
||||
# Start new window manager with extra height for Chrome header
|
||||
Xvfb :$SCR -screen 0 "$XVFB_DIMENSIONS"x24 -ac &
|
||||
export PID_XVFB=$!
|
||||
sleep 2
|
||||
|
||||
# Debug with VNC
|
||||
x11vnc -localhost -rfbport 5900 -passwd "bierbier" -display :$SCR &
|
||||
export PID_VNC=$!
|
||||
|
||||
# Start mqtt broker
|
||||
mosquitto &
|
||||
export PID_MOSQUITTO=$!
|
||||
sleep 2
|
||||
npx -y playwright install
|
||||
|
||||
# Start MQTT Explorer in browser mode
|
||||
export MQTT_EXPLORER_USERNAME=admin
|
||||
export MQTT_EXPLORER_PASSWORD=password
|
||||
export MQTT_EXPLORER_SKIP_AUTH=true
|
||||
export DISPLAY=:$SCR
|
||||
node dist/src/server.js &
|
||||
export PID_SERVER=$!
|
||||
sleep 5
|
||||
|
||||
# Delete old video
|
||||
rm -f ./app-mobile*.mp4
|
||||
rm -f ./qrawvideorgb24-mobile.yuv
|
||||
|
||||
# Start recording in tmux with vertical offset to exclude Chrome header
|
||||
# Record only the actual mobile viewport (412x914), skipping the 88px Chrome header at top
|
||||
tmux new-session -d -s record-mobile ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR+0,$CHROME_HEADER_HEIGHT -r 20 -vcodec rawvideo -pix_fmt yuv420p qrawvideorgb24-mobile.yuv
|
||||
|
||||
# Start tests
|
||||
export BROWSER_MODE_URL=http://localhost:3000
|
||||
DISPLAY=:$SCR node dist/src/spec/demoVideoMobile.js
|
||||
TEST_EXIT_CODE=$?
|
||||
echo "Test script exited with $TEST_EXIT_CODE"
|
||||
|
||||
# Stop recording
|
||||
tmux send-keys -t record-mobile q
|
||||
|
||||
# Ensure video is written
|
||||
sleep 5
|
||||
|
||||
exit $TEST_EXIT_CODE
|
||||
@@ -12,7 +12,7 @@ import ConfigStorage from '../backend/src/ConfigStorage'
|
||||
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
|
||||
import { Rpc } from '../events/EventSystem/Rpc'
|
||||
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
|
||||
import { getAppVersion, writeToFile, readFromFile, addMqttConnectionEvent } from '../events'
|
||||
import { getAppVersion, writeToFile, readFromFile } from '../events'
|
||||
import { RpcEvents } from '../events/EventsV2'
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -244,6 +215,17 @@ async function startServer() {
|
||||
next()
|
||||
})
|
||||
|
||||
// Send auth status to clients on connection
|
||||
io.on('connection', (socket) => {
|
||||
// Inform client about auth status
|
||||
const authDisabled = (socket as any).authDisabled === true
|
||||
socket.emit('auth-status', { authDisabled })
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Client connected, auth disabled: ${authDisabled}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize backend event bus with Socket.io
|
||||
const backendEvents = new SocketIOServerEventBus(io)
|
||||
const backendRpc = new Rpc(backendEvents)
|
||||
@@ -256,59 +238,6 @@ async function startServer() {
|
||||
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
|
||||
configStorage.init()
|
||||
|
||||
// Send auth status to clients on connection
|
||||
io.on('connection', (socket) => {
|
||||
// Inform client about auth status
|
||||
const authDisabled = (socket as any).authDisabled === true
|
||||
socket.emit('auth-status', { authDisabled })
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Client connected, auth disabled: ${authDisabled}`)
|
||||
}
|
||||
|
||||
// Auto-connect to MQTT broker if configured via environment variables
|
||||
const autoConnectHost = process.env.MQTT_AUTO_CONNECT_HOST
|
||||
if (autoConnectHost) {
|
||||
const connectionId = 'auto-connect-' + Date.now()
|
||||
|
||||
// Notify client immediately that auto-connect will happen
|
||||
socket.emit('auto-connect-initiated', { connectionId })
|
||||
|
||||
// Delay auto-connect to give client time to subscribe to events
|
||||
setTimeout(() => {
|
||||
const protocol = process.env.MQTT_AUTO_CONNECT_PROTOCOL || 'mqtt'
|
||||
const port = parseInt(process.env.MQTT_AUTO_CONNECT_PORT || '1883')
|
||||
const tls = protocol.endsWith('s') // mqtts or wss
|
||||
const url = `${protocol}://${autoConnectHost}:${port}`
|
||||
|
||||
const autoConnectConfig = {
|
||||
id: connectionId,
|
||||
options: {
|
||||
url,
|
||||
username: process.env.MQTT_AUTO_CONNECT_USERNAME,
|
||||
password: process.env.MQTT_AUTO_CONNECT_PASSWORD,
|
||||
tls,
|
||||
certValidation: false,
|
||||
clientId: process.env.MQTT_AUTO_CONNECT_CLIENT_ID || 'mqtt-explorer-' + Math.random().toString(16).substr(2, 8),
|
||||
subscriptions: [{ topic: '#', qos: 0 as 0 | 1 | 2 }], // Subscribe to all topics
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
console.log('Auto-connecting to MQTT broker:', {
|
||||
connectionId,
|
||||
url: autoConnectConfig.options.url,
|
||||
clientId: autoConnectConfig.options.clientId,
|
||||
username: autoConnectConfig.options.username || '(none)',
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger connection via backend events
|
||||
backendEvents.emit(addMqttConnectionEvent, autoConnectConfig)
|
||||
}, 1000) // 1 second delay to allow client to set up event subscriptions
|
||||
}
|
||||
})
|
||||
|
||||
// Setup RPC handlers for file operations
|
||||
backendRpc.on(makeOpenDialogRpc(), async request => {
|
||||
// In browser mode, file selection is handled client-side via upload
|
||||
|
||||
@@ -22,14 +22,6 @@ export type SceneNames =
|
||||
| 'keyboard_shortcuts'
|
||||
| 'sparkplugb-decoding'
|
||||
| 'end'
|
||||
| 'mobile_intro'
|
||||
| 'mobile_connect'
|
||||
| 'mobile_browse_topics'
|
||||
| 'mobile_view_message'
|
||||
| 'mobile_search'
|
||||
| 'mobile_json_view'
|
||||
| 'mobile_settings'
|
||||
| 'mobile_end'
|
||||
|
||||
export const SCENE_TITLES: Record<SceneNames, string> = {
|
||||
connect: 'Connecting to MQTT Broker',
|
||||
@@ -47,14 +39,6 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
|
||||
keyboard_shortcuts: 'Keyboard Shortcuts',
|
||||
'sparkplugb-decoding': 'SparkplugB Decoding',
|
||||
end: 'The End',
|
||||
mobile_intro: 'MQTT Explorer on Mobile',
|
||||
mobile_connect: 'Connect to MQTT Broker',
|
||||
mobile_browse_topics: 'Browse Topic Tree',
|
||||
mobile_view_message: 'View Message Details',
|
||||
mobile_search: 'Search Topics',
|
||||
mobile_json_view: 'JSON Message Formatting',
|
||||
mobile_settings: 'Settings with Disconnect/Logout',
|
||||
mobile_end: 'Mobile-Friendly MQTT Explorer',
|
||||
}
|
||||
|
||||
export class SceneBuilder {
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
|
||||
import { Browser, BrowserContext, Page, chromium } from 'playwright'
|
||||
|
||||
import mockMqtt, { stop as stopMqtt } from './mock-mqtt'
|
||||
import { default as MockSparkplug } from './mock-sparkplugb'
|
||||
import { clearSearch, searchTree } from './scenarios/searchTree'
|
||||
import { clickOn, clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
|
||||
import { connectTo } from './scenarios/connect'
|
||||
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
|
||||
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
|
||||
import { disconnect } from './scenarios/disconnect'
|
||||
import { publishTopic } from './scenarios/publishTopic'
|
||||
import { Scene, SceneBuilder } from './SceneBuilder'
|
||||
import { showAdvancedConnectionSettings } from './scenarios/showAdvancedConnectionSettings'
|
||||
import { showJsonPreview } from './scenarios/showJsonPreview'
|
||||
import { showMenu } from './scenarios/showMenu'
|
||||
import { showNumericPlot } from './scenarios/showNumericPlot'
|
||||
import { showOffDiffCapability } from './scenarios/showOffDiffCapability'
|
||||
import { expandTopic } from './util/expandTopic'
|
||||
import { selectTopic } from './util/selectTopic'
|
||||
|
||||
/**
|
||||
* Mobile Demo Video - Pixel 6 viewport
|
||||
*
|
||||
* This demo showcases MQTT Explorer running in a mobile browser viewport
|
||||
* simulating a Google Pixel 6 (412x915px portrait mode)
|
||||
*/
|
||||
|
||||
/**
|
||||
* A convenience method that handles gracefully cleaning up the test run.
|
||||
*/
|
||||
const cleanUp = async (scenes: SceneBuilder, browser: Browser) => {
|
||||
// Exit app.
|
||||
fs.writeFileSync('scenes-mobile.json', JSON.stringify(scenes.scenes, undefined, ' '))
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
process.on('unhandledRejection' as any, (error: Error | any) => {
|
||||
console.error('unhandledRejection', error.message, error.stack)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
console.error('Timeout reached')
|
||||
process.exit(1)
|
||||
},
|
||||
60 * 10 * 1000
|
||||
)
|
||||
|
||||
async function doStuff() {
|
||||
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
|
||||
const brokerPort = process.env.TESTS_MQTT_BROKER_PORT || '1883'
|
||||
console.log(`Waiting for MQTT Broker at ${brokerHost}:${brokerPort} (no auth)`)
|
||||
await mockMqtt()
|
||||
|
||||
console.log('Starting playwright/chromium in mobile mode (Pixel 6)')
|
||||
|
||||
// Launch Chromium browser with mobile emulation
|
||||
// headless: false is required so the browser renders to the X display for video recording
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--app=http://localhost:3000', // App mode - no browser UI
|
||||
'--window-size=412,914', // Match the mobile viewport size
|
||||
'--window-position=0,0',
|
||||
'--disable-features=TranslateUI',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-infobars',
|
||||
'--disable-translate',
|
||||
],
|
||||
})
|
||||
|
||||
// Create browser context with Pixel 6 viewport
|
||||
// Note: Height must be even for video encoding (h264 requirement)
|
||||
const context = await browser.newContext({
|
||||
viewport: {
|
||||
width: 412,
|
||||
height: 914, // Changed from 915 to 914 (must be even for h264)
|
||||
},
|
||||
deviceScaleFactor: 2.625,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36',
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
|
||||
// Navigate to the browser mode server
|
||||
const serverUrl = process.env.BROWSER_MODE_URL || 'http://localhost:3000'
|
||||
console.log(`Navigating to ${serverUrl}`)
|
||||
await page.goto(serverUrl, { waitUntil: 'networkidle' })
|
||||
|
||||
// Print the title
|
||||
console.log(await page.title())
|
||||
|
||||
// Try to capture a screenshot (may fail in headed mode, but that's ok)
|
||||
try {
|
||||
await page.screenshot({ path: 'intro-mobile.png' })
|
||||
} catch (error) {
|
||||
console.log('Screenshot skipped (headed mode)')
|
||||
}
|
||||
|
||||
// Direct console to Node terminal
|
||||
page.on('console', console.log)
|
||||
|
||||
// Enable the fake mouse pointer for visual cursor tracking
|
||||
await createFakeMousePointer(page)
|
||||
|
||||
// Handle authentication if required
|
||||
const username = process.env.MQTT_EXPLORER_USERNAME || 'admin'
|
||||
const password = process.env.MQTT_EXPLORER_PASSWORD || 'password'
|
||||
|
||||
console.log('Waiting for page to initialize...')
|
||||
await sleep(3000)
|
||||
|
||||
// Check for login dialog
|
||||
const loginDialog = page.locator('h2:has-text("Login to MQTT Explorer")')
|
||||
let loginDialogVisible = false
|
||||
try {
|
||||
loginDialogVisible = await loginDialog.isVisible({ timeout: 5000 })
|
||||
} catch (error) {
|
||||
console.log('Login dialog not found - auth may be disabled')
|
||||
}
|
||||
|
||||
if (loginDialogVisible) {
|
||||
console.log('Handling authentication...')
|
||||
const usernameInput = page.locator('input[name="username"]')
|
||||
const passwordInput = page.locator('input[name="password"]')
|
||||
const loginButton = page.locator('button:has-text("Login")')
|
||||
|
||||
await usernameInput.fill(username)
|
||||
await passwordInput.fill(password)
|
||||
await loginButton.click()
|
||||
await sleep(2000)
|
||||
}
|
||||
|
||||
// Wait for the connection UI to be visible
|
||||
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
|
||||
|
||||
const scenes = new SceneBuilder()
|
||||
|
||||
await scenes.record('mobile_intro', async () => {
|
||||
await showText('MQTT Explorer on Mobile', 2000, page, 'middle')
|
||||
await sleep(2500)
|
||||
await showText('Google Pixel 6 (412x915)', 1500, page, 'middle')
|
||||
await sleep(2000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_connect', async () => {
|
||||
await showText('Connect to MQTT Broker', 1500, page, 'top')
|
||||
await connectTo(brokerHost, page)
|
||||
await MockSparkplug.run() // Start sparkplug client after connect
|
||||
await sleep(3000) // Give more time for topics to load
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_browse_topics', async () => {
|
||||
await showText('Browse Topics - Topics Tab', 1500, page, 'top')
|
||||
await sleep(2000)
|
||||
// Wait for tree nodes to be visible
|
||||
await page.waitForSelector('[data-test-topic]', { timeout: 10000 }).catch(() => {
|
||||
console.log('Tree nodes not found, continuing...')
|
||||
})
|
||||
await sleep(1000)
|
||||
|
||||
try {
|
||||
// Expand topics using the expandTopic utility
|
||||
// On mobile, this clicks expand buttons (▶/▼) to navigate the tree
|
||||
await showText('Expand Topic Tree', 1000, page, 'top')
|
||||
await sleep(500)
|
||||
await expandTopic('livingroom/lamp', page)
|
||||
await sleep(1500)
|
||||
} catch (error) {
|
||||
console.log('Topic expansion failed, continuing...', error)
|
||||
}
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_view_message', async () => {
|
||||
await showText('Tap Topic to View Details', 1500, page, 'top')
|
||||
await sleep(1000)
|
||||
|
||||
try {
|
||||
// Select a topic by clicking its text
|
||||
// On mobile, this will switch to the Details tab automatically
|
||||
await selectTopic('livingroom/lamp/state', page)
|
||||
await sleep(2000)
|
||||
// The mobile UI should now show the Details tab with the selected topic
|
||||
await showText('Details Tab Activated', 1000, page, 'top')
|
||||
await sleep(1500)
|
||||
} catch (error) {
|
||||
console.log('Topic selection failed, continuing...', error)
|
||||
}
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_search', async () => {
|
||||
await showText('Search Topics', 1500, page, 'top')
|
||||
await sleep(500)
|
||||
await searchTree('temp', page)
|
||||
await sleep(1500)
|
||||
await showText('Filter Results', 1000, page, 'top')
|
||||
await sleep(1500)
|
||||
await clearSearch(page)
|
||||
await sleep(1000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_json_view', async () => {
|
||||
await showText('JSON Message Formatting', 1500, page, 'top')
|
||||
await sleep(1000)
|
||||
|
||||
try {
|
||||
// Navigate back to Topics tab to show tree navigation
|
||||
const topicsTab = page.locator('button:has-text("TOPICS"), button:has-text("Topics")')
|
||||
const topicsTabVisible = await topicsTab.isVisible().catch(() => false)
|
||||
if (topicsTabVisible) {
|
||||
await topicsTab.click()
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
// Expand and select kitchen/coffee_maker to show JSON
|
||||
await expandTopic('kitchen/coffee_maker', page)
|
||||
await sleep(1000)
|
||||
await selectTopic('kitchen/coffee_maker', page)
|
||||
await sleep(2000)
|
||||
|
||||
await showText('JSON Payload View', 1000, page, 'top')
|
||||
await sleep(1500)
|
||||
} catch (error) {
|
||||
console.log('JSON view navigation failed, continuing...', error)
|
||||
}
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_settings', async () => {
|
||||
try {
|
||||
await showText('Settings with Disconnect/Logout', 1500, page, 'top')
|
||||
await sleep(2000)
|
||||
// Just show that settings are available, don't click
|
||||
await hideText(page)
|
||||
} catch (error) {
|
||||
console.log('Settings scene failed, continuing...', error)
|
||||
// Try to dismiss any error dialogs
|
||||
try {
|
||||
const closeButton = page.locator('button:has-text("Close"), button[aria-label="close"]')
|
||||
if (await closeButton.isVisible().catch(() => false)) {
|
||||
await closeButton.click()
|
||||
await sleep(500)
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if we can't close dialog
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await scenes.record('mobile_end', async () => {
|
||||
await showText('Mobile-Friendly MQTT Explorer', 2000, page, 'middle')
|
||||
await sleep(2500)
|
||||
await showText('Ready for Optimization', 1500, page, 'middle')
|
||||
await sleep(2000)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Forced quit')
|
||||
process.exit(0)
|
||||
}, 10 * 1000)
|
||||
|
||||
stopMqtt()
|
||||
console.log('Stopped mqtt client')
|
||||
|
||||
await cleanUp(scenes, browser)
|
||||
|
||||
// Force exit since there appear to be open handles
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
doStuff()
|
||||
@@ -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)
|
||||
@@ -4,12 +4,7 @@ import { Page } from 'playwright'
|
||||
export async function connectTo(host: string, browser: Page) {
|
||||
await setTextInInput('Host', host, browser)
|
||||
|
||||
// Try to capture screenshot (may fail in headed mode)
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen1.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode, that's ok
|
||||
}
|
||||
await browser.screenshot({ path: 'screen1.png' })
|
||||
|
||||
// Use data-testid for reliable button location
|
||||
const connectButton = browser.locator('[data-testid="connect-button"]')
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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')
|
||||
await clickOn(saveButton, 1)
|
||||
}
|
||||
@@ -3,10 +3,6 @@ import { expandTopic, sleep } from '../util'
|
||||
|
||||
export async function showJsonPreview(browser: Page) {
|
||||
await expandTopic('actuality/showcase', browser)
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen3.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode
|
||||
}
|
||||
await browser.screenshot({ path: 'screen3.png' })
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ export async function showMenu(browser: Page) {
|
||||
// moveToCenterOfElement(brokerStatistics, browser)
|
||||
await sleep(2000)
|
||||
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen4.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode
|
||||
}
|
||||
await browser.screenshot({ path: 'screen4.png' })
|
||||
|
||||
const topicOrder = await browser.locator('//input[@name="node-order"]/../div')
|
||||
await clickOn(topicOrder)
|
||||
@@ -28,11 +24,7 @@ export async function showMenu(browser: Page) {
|
||||
const themeSwitch = await browser.locator('[data-testid="dark-mode-toggle"]')
|
||||
await clickOn(themeSwitch)
|
||||
await sleep(3000)
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen_dark_mode.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode
|
||||
}
|
||||
await browser.screenshot({ path: 'screen_dark_mode.png' })
|
||||
await clickOn(themeSwitch)
|
||||
|
||||
await clickOn(menuButton)
|
||||
|
||||
@@ -2,28 +2,21 @@ import { Page } from 'playwright'
|
||||
import { moveToCenterOfElement, clickOn, clickOnHistory, expandTopic, sleep } from '../util'
|
||||
|
||||
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)
|
||||
@@ -37,11 +30,7 @@ export async function showNumericPlot(browser: Page) {
|
||||
await clickAway('temperature', browser)
|
||||
await sleep(2500)
|
||||
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen_chart_panel.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode
|
||||
}
|
||||
await browser.screenshot({ path: 'screen_chart_panel.png' })
|
||||
|
||||
await removeChart('heater', browser)
|
||||
await sleep(750)
|
||||
@@ -86,9 +75,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()
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@ import { expandTopic, sleep } from '../util'
|
||||
export async function showSparkPlugDecoding(browser: Page) {
|
||||
// spell-checker: disable-next-line
|
||||
await expandTopic('spBv1.0/Sparkplug Devices/DDATA/JavaScript Edge Node/Emulated Device', browser)
|
||||
try {
|
||||
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
|
||||
} catch (error) {
|
||||
// Screenshot may fail in headed mode
|
||||
}
|
||||
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { MqttClient } from 'mqtt'
|
||||
* Tests the core UI functionality using a single connection.
|
||||
* All topics are published before connecting, and tests run sequentially
|
||||
* on the same connected application instance.
|
||||
*
|
||||
*
|
||||
* Supports both Electron and Browser modes:
|
||||
* - Electron mode: Default behavior, launches Electron app
|
||||
* - Browser mode: Set BROWSER_MODE_URL environment variable to the server URL
|
||||
@@ -61,10 +61,6 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
throw new Error('BROWSER_MODE_URL environment variable must be set when running in browser mode')
|
||||
}
|
||||
console.log(`Browser URL: ${browserUrl}`)
|
||||
|
||||
// Check if mobile viewport should be used
|
||||
const useMobileViewport = process.env.USE_MOBILE_VIEWPORT === 'true'
|
||||
console.log(`Mobile viewport: ${useMobileViewport}`)
|
||||
|
||||
// Launch Chromium browser
|
||||
browser = await chromium.launch({
|
||||
@@ -72,29 +68,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
})
|
||||
|
||||
// Create browser context with optional mobile viewport
|
||||
const contextOptions: any = {
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}
|
||||
|
||||
if (useMobileViewport) {
|
||||
// Use same viewport as mobile demo (Pixel 6)
|
||||
contextOptions.viewport = {
|
||||
width: 412,
|
||||
height: 914,
|
||||
}
|
||||
contextOptions.userAgent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36'
|
||||
console.log('Using mobile viewport: 412x914 (Pixel 6)')
|
||||
} else {
|
||||
// Desktop viewport - ensure width > 768px so mobile UI doesn't activate
|
||||
contextOptions.viewport = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
}
|
||||
console.log('Using desktop viewport: 1280x720')
|
||||
}
|
||||
|
||||
browserContext = await browser.newContext(contextOptions)
|
||||
browserContext = await browser.newContext()
|
||||
page = await browserContext.newPage()
|
||||
|
||||
// Listen for console messages
|
||||
@@ -120,13 +94,10 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
// Timeout is expected if dialog is not shown, not an error
|
||||
console.log('Login dialog not found (timeout) - checking if auth is disabled')
|
||||
}
|
||||
|
||||
|
||||
// Debug: print page content to see what's rendered
|
||||
if (!loginDialogVisible) {
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => 'Unable to read body')
|
||||
const body = await page.locator('body').textContent().catch(() => 'Unable to read body')
|
||||
console.log('Page body text:', body?.substring(0, 300))
|
||||
}
|
||||
|
||||
@@ -266,139 +237,4 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-search-lamp.png' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clipboard Operations', () => {
|
||||
it('should copy topic path to clipboard in both Electron and browser modes', async function () {
|
||||
// Given: A topic is selected
|
||||
await clearSearch(page)
|
||||
await sleep(1000)
|
||||
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()
|
||||
await copyTopicButton.click()
|
||||
await sleep(500)
|
||||
|
||||
// Then: Clipboard should contain the topic path
|
||||
const clipboardText = await page.evaluate(async () => {
|
||||
try {
|
||||
// Try to read from clipboard using the Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
// Fallback: try to paste into a temporary input element
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
document.execCommand('paste')
|
||||
const text = input.value
|
||||
document.body.removeChild(input)
|
||||
return text
|
||||
} catch (error) {
|
||||
// If clipboard access fails, return empty string
|
||||
console.warn('Clipboard read failed:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Verify clipboard contains expected topic path
|
||||
if (clipboardText) {
|
||||
expect(clipboardText).to.equal('livingroom/lamp/state')
|
||||
} else {
|
||||
// If clipboard reading is not available, at least verify the button was clicked
|
||||
console.warn('Clipboard verification not available in this environment')
|
||||
const copyButton = await copyTopicButton.isVisible()
|
||||
expect(copyButton).to.be.true
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-copy-topic.png' })
|
||||
})
|
||||
|
||||
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
|
||||
await copyValueButton.click()
|
||||
await sleep(500)
|
||||
|
||||
// Then: Clipboard should contain the message value
|
||||
const clipboardText = await page.evaluate(async () => {
|
||||
try {
|
||||
// Try to read from clipboard using the Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
// Fallback: try to paste into a temporary input element
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
document.execCommand('paste')
|
||||
const text = input.value
|
||||
document.body.removeChild(input)
|
||||
return text
|
||||
} catch (error) {
|
||||
// If clipboard access fails, return empty string
|
||||
console.warn('Clipboard read failed:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Verify clipboard contains expected value (should be "on" from livingroom/lamp/state)
|
||||
if (clipboardText) {
|
||||
expect(clipboardText).to.equal('on')
|
||||
} else {
|
||||
// If clipboard reading is not available, at least verify the button was clicked
|
||||
console.warn('Clipboard verification not available in this environment')
|
||||
const copyButton = await copyValueButton.isVisible()
|
||||
expect(copyButton).to.be.true
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-copy-value.png' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Save/Download Operations', () => {
|
||||
it('should save/download message to file in both Electron and browser modes', async function () {
|
||||
// Given: A topic with a message is already selected from previous test
|
||||
await sleep(500)
|
||||
|
||||
if (isBrowserMode) {
|
||||
// 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')
|
||||
await saveButton.click()
|
||||
|
||||
// Then: Download should be triggered
|
||||
const download = await downloadPromise
|
||||
expect(download).to.not.be.undefined
|
||||
|
||||
// Verify download has a filename
|
||||
const filename = download.suggestedFilename()
|
||||
expect(filename).to.include('mqtt-message-')
|
||||
console.log('Browser mode: File downloaded:', filename)
|
||||
|
||||
// Save to verify (optional, but helps with debugging)
|
||||
await download.saveAs(`/tmp/${filename}`)
|
||||
} 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 isVisible = await saveButton.isVisible()
|
||||
expect(isVisible).to.be.true
|
||||
|
||||
// Note: In Electron, clicking this would open a native dialog which we can't easily automate
|
||||
// For now, just verify the button exists
|
||||
console.log('Electron mode: Save button is visible (native dialog not tested)')
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-save-message.png' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||