From ed8a7f559e03a9b20135286c52f921d55deb4b39 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:53:29 +0100 Subject: [PATCH] Add observability for LLM topic context inclusion (#1038) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com> Co-authored-by: Thomas Nordquist --- .cspell.json | 27 +- .github/workflows/lint.yml | 38 + .github/workflows/tests.yml | 2 - DEBUG_EXAMPLES.md | 334 + DOCKER.md | 33 + ENV_VARS_EXAMPLE.md | 111 +- LLM_INTEGRATION.md | 57 +- Readme.md | 25 + TESTING_WITH_API.md | 201 + app/package.json | 159 +- app/src/actions/Charts.ts | 16 +- app/src/actions/Connection.ts | 6 +- app/src/actions/ConnectionManager.ts | 8 +- app/src/actions/Global.ts | 12 +- app/src/actions/Publish.ts | 56 +- app/src/actions/Settings.ts | 8 +- app/src/actions/Sidebar.ts | 2 +- app/src/actions/Tree.ts | 9 +- app/src/actions/UpdateNotifier.ts | 20 +- app/src/actions/clearTopic.ts | 4 +- app/src/actions/migrations/Connection.ts | 18 +- app/src/actions/visibleTreeTraversal.ts | 11 +- app/src/autoConnectHandler.ts | 12 +- app/src/browserEventBus.ts | 87 +- app/src/components/AboutDialog.spec.ts | 44 +- app/src/components/AboutDialog.tsx | 32 +- app/src/components/App.tsx | 52 +- app/src/components/BrowserAuthWrapper.tsx | 14 +- .../components/Chart/Chart.domain.spec.tsx | 52 +- app/src/components/Chart/Chart.spec.tsx | 176 +- app/src/components/Chart/Chart.tsx | 56 +- app/src/components/Chart/TooltipComponent.tsx | 8 +- .../Chart/effects/useCustomXDomain.tsx | 13 +- .../Chart/effects/useCustomYDomain.tsx | 4 +- app/src/components/Chart/mapCurveType.tsx | 2 +- .../ChartSettings/ColorSettings.tsx | 45 +- .../ChartSettings/InterpolationSettings.tsx | 44 +- .../ChartPanel/ChartSettings/MoveUp.tsx | 16 +- .../ChartSettings/RangeSettings.tsx | 18 +- .../ChartSettings/SettingsButton.tsx | 2 +- .../ChartPanel/ChartSettings/Size.tsx | 14 +- .../ChartSettings/TimeRangeSettings.tsx | 39 +- .../ChartPanel/ChartSettings/colors.ts | 2 +- .../ChartPanel/ChartSettings/index.tsx | 10 +- app/src/components/ChartPanel/ChartTitle.tsx | 12 +- .../ChartPanel/ChartWithTreeNode.tsx | 2 +- app/src/components/ChartPanel/TopicChart.tsx | 21 +- app/src/components/ChartPanel/index.tsx | 59 +- app/src/components/ConfirmationDialog.tsx | 4 +- .../AdvancedConnectionSettings.tsx | 37 +- .../BrowserCertificateFileSelection.tsx | 26 +- .../CertificateFileSelection.tsx | 24 +- .../ConnectionSetup/Certificates.tsx | 28 +- .../ConnectionSetup/ConnectButton.tsx | 22 +- .../ConnectionSetup/ConnectionSettings.tsx | 116 +- .../ConnectionSetup/ConnectionSetup.tsx | 71 +- .../MobileConnectionSelector.tsx | 19 +- .../ConnectionSetup/ProfileList/AddButton.tsx | 16 +- .../ProfileList/ConnectionItem.tsx | 32 +- .../ConnectionSetup/ProfileList/index.tsx | 35 +- .../ConnectionSetup/Subscriptions.tsx | 18 +- .../ConnectionSetup/ToggleSwitch.tsx | 16 +- app/src/components/Demo/Key.tsx | 8 +- app/src/components/Demo/Mouse.tsx | 21 +- app/src/components/Demo/ShowText.tsx | 11 +- app/src/components/Demo/index.tsx | 3 +- app/src/components/ErrorBoundary.tsx | 13 +- app/src/components/Layout/ContentView.tsx | 111 +- app/src/components/Layout/MobileTabs.tsx | 24 +- app/src/components/Layout/Notification.tsx | 4 +- app/src/components/Layout/PauseButton.tsx | 33 +- app/src/components/Layout/SearchBar.tsx | 46 +- app/src/components/Layout/TitleBar.tsx | 65 +- .../components/LoginDialog.security.spec.tsx | 122 +- app/src/components/LoginDialog.tsx | 10 +- app/src/components/QosSelect.tsx | 6 +- .../SettingsDrawer/BooleanSwitch.tsx | 18 +- .../SettingsDrawer/BrokerStatistics.tsx | 19 +- .../components/SettingsDrawer/Settings.tsx | 66 +- .../components/SettingsDrawer/TimeLocale.tsx | 26 +- app/src/components/Sidebar/AIAssistant.tsx | 461 +- .../Sidebar/CodeDiff/ChartPreview.tsx | 18 +- .../components/Sidebar/CodeDiff/DiffCount.tsx | 6 +- .../components/Sidebar/CodeDiff/Gutters.tsx | 38 +- app/src/components/Sidebar/CodeDiff/index.tsx | 32 +- app/src/components/Sidebar/CodeDiff/style.tsx | 8 +- app/src/components/Sidebar/CodeDiff/util.tsx | 2 +- app/src/components/Sidebar/DetailsTab.tsx | 65 +- app/src/components/Sidebar/HistoryDrawer.tsx | 4 +- app/src/components/Sidebar/MessageId.tsx | 4 +- app/src/components/Sidebar/NodeStats.tsx | 9 +- app/src/components/Sidebar/Panel.tsx | 6 +- app/src/components/Sidebar/Publish/Editor.tsx | 9 +- .../Sidebar/Publish/EditorModeSelect.tsx | 2 +- .../components/Sidebar/Publish/Publish.tsx | 178 +- .../Sidebar/Publish/PublishHistory.tsx | 15 +- .../Sidebar/Publish/QosPublishOption.tsx | 24 +- .../Sidebar/Publish/RetainSwitch.tsx | 22 +- .../components/Sidebar/Publish/TopicInput.tsx | 24 +- app/src/components/Sidebar/PublishTab.tsx | 6 +- app/src/components/Sidebar/Sidebar.tsx | 38 +- .../components/Sidebar/SimpleBreadcrumb.tsx | 20 +- .../TopicPanel/RecursiveTopicDeleteButton.tsx | 8 +- .../components/Sidebar/TopicPanel/Topic.tsx | 14 +- .../Sidebar/TopicPanel/TopicDeleteButton.tsx | 8 +- .../Sidebar/TopicPanel/TopicPanel.tsx | 19 +- .../Sidebar/TopicPanel/TopicTypeButton.tsx | 13 +- .../Sidebar/ValueRenderer/ActionButtons.tsx | 28 +- .../DeleteSelectedTopicButton.tsx | 8 +- .../Sidebar/ValueRenderer/MessageHistory.tsx | 18 +- .../Sidebar/ValueRenderer/ValuePanel.tsx | 39 +- .../Sidebar/ValueRenderer/ValueRenderer.tsx | 18 +- app/src/components/TopicPlot.tsx | 2 +- .../Tree/TreeNode/TreeNodeSubnodes.tsx | 32 +- .../Tree/TreeNode/TreeNodeTitle.tsx | 40 +- .../useAnimationToIndicateTopicUpdate.tsx | 1 + .../TreeNode/effects/useDeleteKeyCallback.tsx | 2 +- .../effects/useIsAllowedToAutoExpandState.tsx | 2 +- .../Tree/TreeNode/effects/useViewModel.tsx | 2 +- .../effects/useViewModelSubscriptions.tsx | 2 +- app/src/components/Tree/TreeNode/index.tsx | 12 +- app/src/components/Tree/TreeNode/styles.ts | 24 +- app/src/components/Tree/index.tsx | 49 +- app/src/components/UpdateNotifier.tsx | 27 +- .../helper/ConnectionHealthIndicator.tsx | 14 +- app/src/components/helper/Copy.tsx | 16 +- app/src/components/helper/DateFormatter.tsx | 10 +- app/src/components/helper/NumberFormatter.tsx | 10 +- app/src/components/helper/Save.tsx | 16 +- .../components/helper/isElementInViewport.ts | 5 +- .../helper/usePollingToFetchTreeNode.tsx | 2 +- .../useUpdateComponentWhenNodeUpdates.tsx | 2 +- app/src/components/hooks/useDecoder.ts | 11 +- app/src/decoders/BinaryDecoder.ts | 2 +- app/src/decoders/SparkplugBDecoder.ts | 5 +- app/src/decoders/index.ts | 1 + app/src/eventBus.ts | 12 +- app/src/index.tsx | 15 +- app/src/model/ConnectionOptions.ts | 4 +- app/src/model/TopicViewModel.ts | 9 +- app/src/reducers/Charts.ts | 2 +- app/src/reducers/Connection.ts | 2 +- app/src/reducers/ConnectionManager.ts | 4 +- app/src/reducers/Settings.ts | 6 +- app/src/reducers/Sidebar.ts | 4 +- app/src/reducers/Tree.ts | 9 +- app/src/reducers/index.ts | 2 +- app/src/reducers/lib.ts | 9 +- app/src/services/llmService.ts | 576 +- app/src/services/spec/README.md | 267 + app/src/services/spec/llmIntegration.spec.ts | 251 + app/src/services/spec/llmProposals.spec.ts | 273 + app/src/services/spec/llmService.spec.ts | 682 + app/src/store.ts | 2 +- app/src/utils/PersistentStorage.ts | 3 +- app/src/utils/spec/ConfigMigrator.spec.ts | 86 +- .../utils/spec/ConnectionsMigration.spec.ts | 4 +- app/src/utils/spec/testUtils.tsx | 55 +- app/yarn.lock | 568 +- backend/package.json | 44 +- docs/LLM_TEST_RESULTS.md | 156 + events/DialogTypes.ts | 6 +- events/EventSystem/EventDispatcher.ts | 1 + events/EventSystem/IpcMainEventBus.ts | 5 +- events/EventSystem/IpcRendererEventBus.ts | 3 +- events/EventSystem/Rpc.ts | 4 +- events/EventSystem/SocketIOServerEventBus.ts | 5 +- events/Events.ts | 2 +- events/EventsV2.ts | 16 +- package-lock.json | 28302 ++++++++++++++++ package.json | 145 +- scripts/cutScenes.ts | 4 +- scripts/run-llm-tests.sh | 71 + src/AuthManager.ts | 2 + src/MenuTemplate.ts | 2 +- src/electron.ts | 20 +- src/server.ts | 298 +- src/spec/SceneBuilder.spec.ts | 18 +- src/spec/SceneBuilder.ts | 1 + src/spec/demoVideoMobile.ts | 35 +- src/spec/expandTopic.spec.ts | 20 +- src/spec/inspect-chart-settings.ts | 26 +- src/spec/mock-mqtt-test.ts | 8 +- src/spec/mock-mqtt.ts | 4 +- src/spec/mock-sparkplugb.ts | 396 +- src/spec/scenarios/connect.ts | 2 +- src/spec/scenarios/showNumericPlot.ts | 6 +- src/spec/ui-tests-comprehensive.spec.ts | 22 +- src/spec/ui-tests.spec.ts | 31 +- src/spec/util/expandTopic.ts | 22 +- src/spec/util/index.ts | 15 +- src/spec/util/selectTopic.ts | 6 +- ux-exploration.ts | 139 +- yarn.lock | 2121 +- 194 files changed, 35234 insertions(+), 4085 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 DEBUG_EXAMPLES.md create mode 100644 TESTING_WITH_API.md create mode 100644 app/src/services/spec/README.md create mode 100644 app/src/services/spec/llmIntegration.spec.ts create mode 100644 app/src/services/spec/llmProposals.spec.ts create mode 100644 app/src/services/spec/llmService.spec.ts create mode 100644 docs/LLM_TEST_RESULTS.md create mode 100644 package-lock.json create mode 100755 scripts/run-llm-tests.sh diff --git a/.cspell.json b/.cspell.json index 755239b..d821bbe 100644 --- a/.cspell.json +++ b/.cspell.json @@ -53,6 +53,31 @@ "noconflict", "sparkplugb", "protojson", - "typesafe" + "typesafe", + "visx", + "socketio", + "uuidv", + "hsts", + "frameguard", + "sameorigin", + "mqtts", + "infobars", + "KHTML", + "networkidle", + "testuser", + "wronguser", + "viewports", + "topicname", + "labelledby", + "dontAddToRecent", + "eclipseprojects", + "tasmota", + "Tasmota", + "automations", + "homeassistant", + "cmnd", + "parseable", + "sonoff", + "tele" ] } \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..fc636fc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: Lint + +on: + pull_request_target: + branches: [master, beta, release] + push: + branches: [master, beta, release] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Run Prettier + run: yarn lint:prettier + + - name: Run ESLint + run: yarn lint:eslint + continue-on-error: true # Allow failures for now due to existing issues + + - name: Run Spellcheck + run: yarn lint:spellcheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d75b6c..3b4e632 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,8 +20,6 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Install Packages run: yarn install --frozen-lockfile - - name: Lint - run: yarn lint:eslint - name: Build run: yarn build - name: Test diff --git a/DEBUG_EXAMPLES.md b/DEBUG_EXAMPLES.md new file mode 100644 index 0000000..1adefe0 --- /dev/null +++ b/DEBUG_EXAMPLES.md @@ -0,0 +1,334 @@ +# Debug Output Examples + +This document shows examples of the enhanced debug output added to MQTT Explorer's AI Assistant. + +## Frontend Debug View + +Click the bug icon (🐛) in the AI Assistant header to toggle the debug view. + +### Example Debug Output + +```json +{ + "systemMessage": { + "role": "system", + "content": "You are an expert AI assistant specializing in MQTT (Message Queuing Telemetry Transport) protocol and home/industrial automation systems.\n\n**Your Core Expertise:**\n- MQTT protocol: topics, QoS levels, retained messages, wildcards, last will and testament\n- IoT and smart home ecosystems: devices, sensors, actuators, and controllers\n- Home automation platforms: Home Assistant, openHAB, Node-RED, MQTT brokers, zigbee2mqtt, tasmota\n- Common MQTT topic patterns and naming conventions (e.g., zigbee2mqtt, tasmota, homie)\n- Data formats: JSON payloads, binary data, sensor readings, state messages\n- Time-series data analysis and pattern recognition\n- Troubleshooting connectivity, message delivery, and data quality issues\n\n**Your Communication Style:**\n- Keep your TEXT response CONCISE and practical (2-3 sentences maximum for the explanation)\n- Use clear technical language appropriate for users familiar with MQTT\n- When analyzing data, identify patterns, anomalies, or potential issues quickly\n- Suggest practical next steps or automations when relevant\n- Reference common MQTT ecosystems and standards when applicable\n- NOTE: Proposals and question suggestions are OUTSIDE the sentence limit - always include them when relevant\n...", + "note": "This is the system prompt that provides context to the LLM" + }, + "messages": [ + { + "index": 0, + "role": "user", + "content": "What does this topic do?", + "fullContent": "Context:\nTopic: home/livingroom/light\nValue: {\"state\":\"ON\",\"brightness\":255}\nRetained: true\n\nRelated Topics (3):\n home/livingroom/thermostat: 21.5\n home/livingroom/motion: false\n home/livingroom/light/set: \n\nMessages: 42\nSubtopics: 3\n\nUser Question: What does this topic do?", + "timestamp": "2026-01-30T13:20:15.123Z", + "proposals": 0, + "questionProposals": 0, + "apiDebug": { + "provider": "openai", + "model": "gpt-5-mini", + "timing": { + "duration_ms": 1234, + "timestamp": "2026-01-30T13:20:15.123Z" + }, + "request": { + "url": "https://api.openai.com/v1/chat/completions", + "body": { + "model": "gpt-5-mini", + "messages": [ + { + "role": "system", + "content": "You are an expert AI assistant..." + }, + { + "role": "user", + "content": "Context:\nTopic: home/livingroom/light\n..." + } + ], + "max_completion_tokens": 500 + } + }, + "response": { + "id": "chatcmpl-AbCdEfGh123456", + "model": "gpt-5-mini", + "created": 1738247815, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "This topic represents a smart light in your living room..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 156, + "completion_tokens": 98, + "total_tokens": 254 + }, + "system_fingerprint": "fp_abc123def456" + } + } + }, + { + "index": 1, + "role": "assistant", + "content": "This topic represents a smart light in your living room...", + "fullContent": "This topic represents a smart light in your living room. It's currently ON at full brightness (255). The topic follows a typical Home Assistant or MQTT smart home pattern.\n\n```proposal\n{\n \"topic\": \"home/livingroom/light/set\",\n \"payload\": \"OFF\",\n \"qos\": 0,\n \"description\": \"Turn off the living room light\"\n}\n```", + "timestamp": "2026-01-30T13:20:16.357Z", + "proposals": 1, + "questionProposals": 2 + } + ], + "summary": { + "totalMessages": 2, + "messagesWithDebugInfo": 1, + "lastApiCall": "2026-01-30T13:20:15.123Z" + } +} +``` + +## Server Console Output + +### Example Request Log + +``` +================================================================================ +LLM REQUEST (OpenAI) +================================================================================ +Provider: openai +Model: gpt-5-mini +Messages Count: 2 + +Full Request Body: +{ + model: 'gpt-5-mini', + messages: [ + { + role: 'system', + content: 'You are an expert AI assistant specializing in MQTT (Message Queuing Telemetry Transport) protocol and home/industrial automation systems.\n' + + '\n' + + '**Your Core Expertise:**\n' + + '- MQTT protocol: topics, QoS levels, retained messages, wildcards, last will and testament\n' + + '- IoT and smart home ecosystems: devices, sensors, actuators, and controllers\n' + + '- Home automation platforms: Home Assistant, openHAB, Node-RED, MQTT brokers, zigbee2mqtt, tasmota\n' + + '- Common MQTT topic patterns and naming conventions (e.g., zigbee2mqtt, tasmota, homie)\n' + + '- Data formats: JSON payloads, binary data, sensor readings, state messages\n' + + '- Time-series data analysis and pattern recognition\n' + + '- Troubleshooting connectivity, message delivery, and data quality issues\n' + + '\n' + + '**Your Communication Style:**\n' + + '- Keep your TEXT response CONCISE and practical (2-3 sentences maximum for the explanation)\n' + + '- Use clear technical language appropriate for users familiar with MQTT\n' + + '- When analyzing data, identify patterns, anomalies, or potential issues quickly\n' + + '- Suggest practical next steps or automations when relevant\n' + + '- Reference common MQTT ecosystems and standards when applicable\n' + + '- NOTE: Proposals and question suggestions are OUTSIDE the sentence limit - always include them when relevant\n' + + '...' + }, + { + role: 'user', + content: 'Context:\n' + + 'Topic: home/livingroom/light\n' + + 'Value: {"state":"ON","brightness":255}\n' + + 'Retained: true\n' + + '\n' + + 'Related Topics (3):\n' + + ' home/livingroom/thermostat: 21.5\n' + + ' home/livingroom/motion: false\n' + + ' home/livingroom/light/set: \n' + + '\n' + + 'Messages: 42\n' + + 'Subtopics: 3\n' + + '\n' + + 'User Question: What does this topic do?' + } + ], + max_completion_tokens: 500 +} + +System Message: +{ + role: 'system', + content: 'You are an expert AI assistant specializing in MQTT (Message Queuing Telemetry Transport) protocol and home/industrial automation systems.\n' + + '\n' + + '**Your Core Expertise:**\n' + + '- MQTT protocol: topics, QoS levels, retained messages, wildcards, last will and testament\n' + + '...' +} +================================================================================ +``` + +### Example Response Log + +``` +================================================================================ +LLM RESPONSE (OpenAI) +================================================================================ +Duration: 1234 ms + +Full Response: +{ + id: 'chatcmpl-AbCdEfGh123456', + object: 'chat.completion', + created: 1738247815, + model: 'gpt-5-mini', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'This topic represents a smart light in your living room. It\'s currently ON at full brightness (255). The topic follows a typical Home Assistant or MQTT smart home pattern.\n' + + '\n' + + '```proposal\n' + + '{\n' + + ' "topic": "home/livingroom/light/set",\n' + + ' "payload": "OFF",\n' + + ' "qos": 0,\n' + + ' "description": "Turn off the living room light"\n' + + '}\n' + + '```\n' + + '\n' + + '```question-proposal\n' + + '{\n' + + ' "question": "What other devices are in the living room?",\n' + + ' "category": "analysis"\n' + + '}\n' + + '```\n' + + '\n' + + '```question-proposal\n' + + '{\n' + + ' "question": "How can I dim the light to 50%?",\n' + + ' "category": "control"\n' + + '}\n' + + '```' + }, + logprobs: null, + finish_reason: 'stop' + } + ], + usage: { + prompt_tokens: 156, + completion_tokens: 98, + total_tokens: 254 + }, + system_fingerprint: 'fp_abc123def456' +} +================================================================================ + +================================================================================ +LLM RPC HANDLER - Returning response +================================================================================ +Response length: 456 +Has debugInfo: true +================================================================================ +``` + +### Example Error Log + +If an error occurs: + +``` +================================================================================ +LLM RPC ERROR +================================================================================ +Error message: Invalid API key configuration +Error stack: Error: Invalid API key configuration + at /home/runner/work/MQTT-Explorer/MQTT-Explorer/dist/src/server.js:642:15 + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) +Full error: Error: Invalid API key configuration + at /home/runner/work/MQTT-Explorer/MQTT-Explorer/dist/src/server.js:642:15 { + status: 401, + type: 'invalid_request_error', + code: 'invalid_api_key' +} +================================================================================ +``` + +## Browser Console Output + +### Normal Flow + +```javascript +LLM Service: Received result from backend: { + response: "This topic represents a smart light...", + debugInfo: { + provider: "openai", + model: "gpt-5-mini", + timing: { duration_ms: 1234, timestamp: "2026-01-30T13:20:15.123Z" }, + request: { url: "...", body: {...} }, + response: { id: "chatcmpl-...", usage: {...} } + } +} +LLM Service: Has response: true +LLM Service: Has debugInfo: true +LLM Service: Assistant message length: 456 +LLM Service: Debug info: { provider: "openai", model: "gpt-5-mini", ... } +``` + +### Error Flow + +```javascript +LLM Service: Received result from backend: undefined +LLM Service: Has response: false +LLM Service: Has debugInfo: false +LLM Service: Invalid result from backend: undefined +AI Assistant error: Error: No response from AI assistant + at LLMService.sendMessage (llmService.ts:440) +Error details: { message: "No response from AI assistant" } +``` + +## Key Features + +### No Truncation + +Notice how the system message and all content is shown in full, without any "..." truncation: + +- **Before:** Objects were truncated at depth 2, arrays at 10 items +- **After:** Complete objects shown with `depth: null`, full arrays with `maxArrayLength: null` + +### Color Coding + +In the terminal, the output is color-coded: +- Strings: Green +- Numbers: Yellow +- Booleans: Yellow +- Null/Undefined: Gray +- Object keys: Cyan + +### Visual Separators + +Clear visual boundaries between sections: +- `===` lines separate major sections +- Consistent formatting makes scanning easier +- Duration and timing info highlighted + +### Complete Context + +Every debug output includes: +- **What**: The operation being performed +- **When**: Timestamp and duration +- **How**: Complete request parameters +- **Result**: Complete response data +- **Why** (if error): Full error with stack trace + +## Usage Tips + +1. **Finding Issues**: Look for the last successful log before error +2. **Performance**: Check `duration_ms` for slow requests +3. **Token Usage**: Monitor `usage.total_tokens` to track costs +4. **Content Issues**: Check `fullContent` to see what was actually sent +5. **System Prompt**: Review `systemMessage` to verify LLM instructions + +## Production Considerations + +While these logs are comprehensive, in production you may want to: + +1. **Add log levels**: Use DEBUG level for detailed logs +2. **Reduce verbosity**: Only log errors in production +3. **Sampling**: Log only 1% of requests for monitoring +4. **Remove colors**: Disable ANSI colors for log aggregation tools +5. **PII filtering**: Redact sensitive data from logs + +Current implementation is optimized for development and debugging. diff --git a/DOCKER.md b/DOCKER.md index fc8bf13..7377232 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -71,6 +71,39 @@ docker-compose up -d | `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.** | +### AI Assistant / LLM Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `LLM_PROVIDER` | No | `openai` | AI provider to use (`openai` or `gemini`) | +| `OPENAI_API_KEY` | No | - | OpenAI API key for AI Assistant (provider-specific) | +| `GEMINI_API_KEY` | No | - | Google Gemini API key for AI Assistant (provider-specific) | +| `LLM_API_KEY` | No | - | Generic API key for AI Assistant (works with either provider) | +| `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT` | No | `500` | Token limit for neighboring topics context in AI queries (increased for better device relationship detection) | + +**Architecture**: The backend proxies all LLM API requests via WebSocket RPC. API keys are **never** sent to the frontend - only an availability flag is transmitted. The frontend calls the backend via WebSocket RPC (`llm/chat` event), and the backend makes requests to OpenAI/Gemini on behalf of the client. + +**Security**: +- ✅ API keys remain server-side only +- ✅ Keys never embedded in client bundles +- ✅ Keys never transmitted to frontend +- ✅ Backend controls all LLM access +- ✅ Communication via secure WebSocket RPC + +**Note**: If no LLM environment variables are set, the AI Assistant feature will be completely hidden from users. + +**Example with AI Assistant**: +```bash +docker run -d \ + -p 3000:3000 \ + -e MQTT_EXPLORER_USERNAME=admin \ + -e MQTT_EXPLORER_PASSWORD=secret \ + -e LLM_PROVIDER=openai \ + -e OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx \ + -e LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 \ + ghcr.io/thomasnordquist/mqtt-explorer:latest +``` + ### Authentication Modes **Standard Mode (Default):** diff --git a/ENV_VARS_EXAMPLE.md b/ENV_VARS_EXAMPLE.md index 72bcc44..fd6f5b1 100644 --- a/ENV_VARS_EXAMPLE.md +++ b/ENV_VARS_EXAMPLE.md @@ -22,13 +22,13 @@ export LLM_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx ```bash # Configure token limit for neighboring topics context -export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 # Default: 100 tokens +export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 # Default: 500 tokens (increased for better device detection) -# Example: Increase token limit for more context +# Example: Increase token limit for large home automation setups +export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=1000 + +# Example: Decrease token limit to reduce API costs (may reduce proposal quality) 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 @@ -45,7 +45,7 @@ export MQTT_AUTO_CONNECT_PORT=1883 # LLM Configuration export LLM_PROVIDER=gemini export GEMINI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxx -export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 +export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 # Start the server node dist/src/server.js @@ -63,7 +63,7 @@ RUN yarn install && yarn build:server # Environment variables can be set at runtime ENV LLM_PROVIDER=openai -ENV LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 +ENV LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 EXPOSE 3000 CMD ["node", "dist/src/server.js"] @@ -74,7 +74,7 @@ CMD ["node", "dist/src/server.js"] docker run -d \ -e OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx \ -e LLM_PROVIDER=openai \ - -e LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 \ + -e LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 \ -e MQTT_AUTO_CONNECT_HOST=mqtt.example.com \ -p 3000:3000 \ mqtt-explorer @@ -82,63 +82,64 @@ docker run -d \ ## 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: +The `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT` controls how many tokens are allocated for neighboring topics in the context. The default has been increased from 100 to 500 tokens to provide better device relationship detection and enable multi-device automation proposals. -### With Default 100 Tokens +### With Default 500 Tokens (Recommended) ``` -Topic Path: sensors/living_room/temperature -Value: 22.5 +Topic Path: home/living_room/light +Value: {"state":"ON","brightness":75,"color_temp":350} 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 +Related Topics (18 shown): + home/living_room: {"scene":"evening"} + home/living_room/thermostat: {"temperature":22.5,"target":23,"mode":"heat"} + home/living_room/motion: true + home/living_room/humidity: 65 + home/living_room/light/set: (command topic) + home/living_room/light/availability: "online" + home/living_room/light/config: {"transition":0.5,"fade":true} + home/living_room/blinds: {"position":75,"state":"open"} + home/living_room/blinds/set: (command topic) + home/living_room/tv: {"power":"ON","input":"HDMI1"} + home/kitchen/light: {"state":"ON","brightness":100} + home/bedroom/light: {"state":"OFF"} + home/living_room/light/brightness/set: (command topic) + home/living_room/light/color/set: (command topic) + home/living_room/motion_sensor/battery: 95 + home/living_room/motion_sensor/last_motion: "2026-01-27T19:45:30Z" -Message Count: 1 -Subtopics: 0 +Message Count: 42 +Subtopics: 5 ``` -### With 50 Tokens (Reduced) +**Benefits:** AI can see full room context (multiple devices), detect controllable devices (set topics), understand device hierarchies (parent/child relationships), and propose coordinated multi-device actions like "turn off all living room devices" or "set evening scene". + +### With 200 Tokens (Reduced - May Miss Context) ``` -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 +Topic Path: home/living_room/light +Value: {"state":"ON","brightness":75,"color_temp":350} 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 + home/living_room: {"scene":"evening"} + home/living_room/thermostat: {"temperature":22.5,"target":23} + home/living_room/motion: true + home/living_room/light/set: (command topic) + home/living_room/blinds: {"position":75} + home/kitchen/light: {"state":"ON","brightness":100} -Message Count: 1 -Subtopics: 0 +Message Count: 42 +Subtopics: 5 ``` +**Limitations:** Less context = fewer multi-device proposals, may miss related devices in same room. + +### With 1000 Tokens (Maximum - For Large Setups) + +Use for extensive home automation with many rooms and devices. Provides comprehensive context including grandchildren, cousins, and extended device relationships. + ## Priority Order The AI Assistant checks configuration in this order: @@ -182,12 +183,18 @@ node -e "console.log(process.env.OPENAI_API_KEY)" ### Token Limit Too Low -If you're seeing truncated context: +If you're seeing truncated context or poor multi-device proposals: ```bash -# Increase the token limit -export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=200 +# Increase the token limit (recommended: 500-1000 for home automation) +export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=1000 ``` +**Note:** The default was increased from 100 to 500 tokens to better support: +- Multi-device detection and relationships +- Hierarchical topic structures (parent → children → grandchildren) +- Room-level automation proposals +- Complex home automation scenarios + ### Want to Use UI Configuration Simply don't set the environment variables - the UI configuration will be used instead. diff --git a/LLM_INTEGRATION.md b/LLM_INTEGRATION.md index 8662621..b770d91 100644 --- a/LLM_INTEGRATION.md +++ b/LLM_INTEGRATION.md @@ -30,18 +30,11 @@ The AI Assistant can help you: ### Setting Up Your API Key -#### Via UI (Browser/Electron) +The AI Assistant uses a **backend proxy architecture** for security. API keys are configured server-side only and are never exposed to the frontend. -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" +#### For Server/Docker Deployments -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: +Configure the AI Assistant using environment variables: ```bash # Provider selection (optional, defaults to 'openai') @@ -52,14 +45,26 @@ 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 +# Token limit for neighboring topics context (optional, defaults to 500) +# Increased from 100 to 500 for better device relationship and hierarchy detection +export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=500 + +# Start the server +node dist/src/server.js ``` +**Architecture**: +- Backend reads API keys from environment variables +- Backend proxies all LLM API requests via WebSocket RPC (`llm/chat` event) +- Frontend only receives an availability flag (no credentials) +- API keys never leave the server +- Communication happens over the existing WebSocket connection + **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 + +**Note**: If no LLM environment variables are set, the AI Assistant feature will be completely hidden from all users. ### Getting API Keys @@ -68,7 +73,7 @@ export LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT=100 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 +4. Set `OPENAI_API_KEY` environment variable on your server **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). @@ -114,11 +119,11 @@ The AI Assistant provides contextual suggestions based on the selected topic: 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 +- **Neighboring Topics**: Related topics with hierarchical context (parent, siblings, children, grandchildren, cousins), limited to 500 tokens by default (increased from 100 for better device relationship detection) - **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. +The neighboring topics context can be adjusted using the `LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT` environment variable. We recommend 500-1000 tokens for production deployments to enable better multi-device and room-level automation proposals. ## Privacy & Security @@ -141,17 +146,31 @@ The neighboring topics context can be adjusted using the `LLM_NEIGHBORING_TOPICS - **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) +- **API Integration**: OpenAI Chat Completions API (GPT-4o Mini by default) - **Context Generation**: Automatic extraction of topic metadata for relevant queries +### Implementation + +The AI Assistant uses: + +- **OpenAI SDK**: Official `openai` package (v6.16.0) for reliable OpenAI API communication + - Automatic retry logic with exponential backoff + - Built-in timeout handling (30 seconds) + - TypeScript type safety + - Better error messages +- **Axios**: Direct HTTP calls for Gemini API (Google doesn't provide official Node.js SDK) +- **Model**: `gpt-5-mini` (latest OpenAI mini model, 400K context window) +- **Architecture**: Server-side proxy - API keys never sent to browser + ### 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 +- **Model Selection**: Defaults to `gpt-5-mini` (latest OpenAI mini model) - **Conversation History**: Automatically manages context (keeps last 10 messages) -- **Timeout Handling**: 30-second timeout for API requests +- **Timeout Handling**: 30-second timeout for API requests with automatic retries +- **Debug Mode**: View complete API request and response data via debug button ## Troubleshooting diff --git a/Readme.md b/Readme.md index bd51eae..e7131ca 100644 --- a/Readme.md +++ b/Readme.md @@ -122,6 +122,31 @@ yarn test:backend yarn test ``` +### LLM Testing + +The AI Assistant feature includes comprehensive tests to validate proposal quality and LLM integration. + +**Offline tests** (default - no API key needed): +```bash +yarn test:app +``` + +**Live LLM integration tests** (requires API key): +```bash +# Set your API key +export OPENAI_API_KEY=sk-your-key-here +# Or use Gemini +export GEMINI_API_KEY=your-key-here + +# Opt-in to live tests +export RUN_LLM_TESTS=true + +# Run tests +yarn test:app +``` + +For detailed LLM testing documentation, see [app/src/services/spec/README.md](app/src/services/spec/README.md). + ### Integration & UI Tests **UI test suite** - Independent, deterministic browser tests: diff --git a/TESTING_WITH_API.md b/TESTING_WITH_API.md new file mode 100644 index 0000000..4d95735 --- /dev/null +++ b/TESTING_WITH_API.md @@ -0,0 +1,201 @@ +# Quick Reference: Running LLM Tests with OpenAI API + +## Prerequisites + +✅ OpenAI API key added to GitHub Copilot environment +✅ Test infrastructure installed (100 offline + 11 live tests) +✅ Helper script available: `scripts/run-llm-tests.sh` + +## Usage + +### Option 1: Use Helper Script (Recommended) + +```bash +# If secret is in environment +./scripts/run-llm-tests.sh + +# Or provide explicitly +OPENAI_API_KEY=sk-your-key ./scripts/run-llm-tests.sh +``` + +### Option 2: Manual Execution + +```bash +# Set environment variables +export OPENAI_API_KEY=sk-your-key +export RUN_LLM_TESTS=true + +# Run tests +cd app && yarn test +``` + +### Option 3: Single Command + +```bash +cd /home/runner/work/MQTT-Explorer/MQTT-Explorer && \ + RUN_LLM_TESTS=true \ + OPENAI_API_KEY=${OPENAI_API_KEY} \ + yarn test:app +``` + +## Expected Results + +### Without Live Tests (Default) +``` + 100 passing (2s) + 11 pending +``` + +### With Live Tests Enabled +``` + LLM Integration Tests (Live API) + Home Automation System Detection + ✓ should detect zigbee2mqtt topics (2145ms) + ✓ should detect Home Assistant topics (1892ms) + ✓ should detect Tasmota topics (1756ms) + + Proposal Quality Validation + ✓ should propose multiple actions (2234ms) + ✓ should provide clear descriptions (1678ms) + ✓ should match system formats (1923ms) + + Edge Cases + ✓ should not propose for sensors (1567ms) + ✓ should handle nested topics (1834ms) + ✓ should handle special chars (1456ms) + + Question Generation + ✓ should generate relevant questions (2012ms) + ✓ should generate analytical questions (1789ms) + + 111 passing (22s) +``` + +## Validation Points + +Each live test validates: + +✅ **Topic Format** +- Matches system pattern (zigbee2mqtt, homeassistant, etc.) +- No wildcards +- Valid segments + +✅ **Payload Quality** +- Valid JSON (when appropriate) +- Correct format for target system +- No injection attempts + +✅ **QoS Value** +- Must be 0, 1, or 2 +- Typically 0 for home automation + +✅ **Description** +- Actionable (uses imperative verbs) +- Clear and concise +- Under 100 characters + +## Troubleshooting + +### Secret Not Available + +If you get "No API key found": + +```bash +# Check environment +env | grep OPENAI_API_KEY + +# If not set, the secret may need to be: +# 1. Refreshed in the Copilot environment +# 2. Made available to the runtime +# 3. Accessed through a different mechanism +``` + +### Tests Still Pending + +If live tests don't run: + +```bash +# Ensure flag is set +export RUN_LLM_TESTS=true +echo $RUN_LLM_TESTS # Should output: true + +# Check for API key +[ -n "$OPENAI_API_KEY" ] && echo "Key is set" || echo "Key not found" +``` + +## What Gets Tested + +### Home Automation Systems + +**zigbee2mqtt:** +```typescript +// Expected proposal +{ + topic: "zigbee2mqtt/light/set", + payload: '{"state": "ON"}', + qos: 0, + description: "Turn on the light" +} +``` + +**Home Assistant:** +```typescript +{ + topic: "homeassistant/light/lamp/set", + payload: "ON", + qos: 0, + description: "Turn on the lamp" +} +``` + +**Tasmota:** +```typescript +{ + topic: "cmnd/device/POWER", + payload: "ON", + qos: 0, + description: "Turn on the device" +} +``` + +### Question Generation + +For a topic like `zigbee2mqtt/bedroom_light`: + +```typescript +// Expected questions +[ + "How can I turn this light on?", + "What brightness levels are supported?", + "Can I adjust the color?", + "How do I automate this light?" +] +``` + +## Cost Estimate + +Per full test run: +- **API Calls:** ~11 requests +- **Tokens:** ~5,000-8,000 total +- **Cost:** ~$0.001-0.002 USD (GPT-4o Mini is ~10x cheaper than GPT-3.5 Turbo) + +## Documentation + +- **Test Strategy:** `app/src/services/spec/README.md` +- **Test Results:** `docs/LLM_TEST_RESULTS.md` +- **Helper Script:** `scripts/run-llm-tests.sh` + +## Success Criteria + +When tests pass, you'll have validated: + +✅ AI can detect home automation systems correctly +✅ Generated proposals have valid MQTT topic format +✅ Payloads match system-specific requirements +✅ Descriptions are clear and actionable +✅ Questions are relevant and diverse +✅ No security issues (injection, size limits) + +--- + +**Ready to test?** Run: `./scripts/run-llm-tests.sh` diff --git a/app/package.json b/app/package.json index b4b9f35..941823f 100644 --- a/app/package.json +++ b/app/package.json @@ -15,94 +15,93 @@ "author": "", "license": "CC-BY-SA-4.0", "dependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.3.6", - "@mui/lab": "^7.0.1-beta.20", - "@mui/material": "^7.3.6", - "@mui/styles": "^6.4.8", - "@react-spring/web": "^9.7.5", - "@types/react-transition-group": "^4.4.11", - "@visx/axis": "^3.10.1", - "@visx/grid": "^3.5.0", - "@visx/tooltip": "^3.3.0", - "@visx/xychart": "^3.10.2", - "ace-builds": "^1.4.11", - "axios": "^1.13.2", - "compare-versions": "^6.1.1", - "copy-text-to-clipboard": "^3.2.0", - "d3": "^7.9.0", - "d3-shape": "^3.2.0", - "diff": "^8.0.3", - "dot-prop": "^5.3.0", - "events": "^3.3.0", - "get-value": "^3.0.1", - "immutable": "^4.3.7", - "in-viewport": "^3.6.0", - "js-base64": "^3.7.8", - "json-to-ast": "^2.1.0", - "lodash.debounce": "^4.0.8", - "lodash.throttle": "^4.1.1", - "moving-average": "^1.0.0", - "number-abbreviate": "^2.0.0", - "os-browserify": "^0.3.0", - "parse-duration": "^0.1.1", - "path-browserify": "^1.0.1", - "prismjs": "^1.29.0", - "react": "^19.2.3", - "react-ace": "^14.0.1", - "react-dom": "^19.2.3", - "react-redux": "^9.2.0", - "react-resize-detector": "^11.0.1", - "react-split-pane": "^0.1.92", - "react-transition-group": "^4.4.5", - "redux": "^5.0.1", - "redux-batched-actions": "^0.5.0", - "redux-thunk": "^3.1.0", - "sha1": "^1.1.1", - "socket.io-client": "^4.8.1", - "url": "^0.11.4", - "uuid": "^11.0.0" + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.6", + "@mui/lab": "7.0.1-beta.20", + "@mui/material": "7.3.6", + "@mui/styles": "6.4.8", + "@react-spring/web": "9.7.5", + "@types/react-transition-group": "4.4.11", + "@visx/axis": "3.10.1", + "@visx/grid": "3.5.0", + "@visx/tooltip": "3.3.0", + "@visx/xychart": "3.10.2", + "ace-builds": "1.4.11", + "axios": "1.13.2", + "compare-versions": "6.1.1", + "copy-text-to-clipboard": "3.2.0", + "d3": "7.9.0", + "d3-shape": "3.2.0", + "diff": "8.0.3", + "dot-prop": "5.3.0", + "events": "3.3.0", + "get-value": "3.0.1", + "immutable": "4.3.7", + "in-viewport": "3.6.0", + "js-base64": "3.7.8", + "json-to-ast": "2.1.0", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "moving-average": "1.0.0", + "number-abbreviate": "2.0.0", + "os-browserify": "0.3.0", + "parse-duration": "0.1.1", + "path-browserify": "1.0.1", + "prismjs": "1.29.0", + "react": "19.2.3", + "react-ace": "14.0.1", + "react-dom": "19.2.3", + "react-redux": "9.2.0", + "react-resize-detector": "11.0.1", + "react-split-pane": "0.1.92", + "react-transition-group": "4.4.5", + "redux": "5.0.1", + "redux-batched-actions": "0.5.0", + "redux-thunk": "3.1.0", + "sha1": "1.1.1", + "socket.io-client": "4.8.1", + "url": "0.11.4", + "uuid": "11.0.0" }, "devDependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "7.28.4", "@reduxjs/toolkit": "2.5.0", "@testing-library/dom": "10.4.0", "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", - "@types/d3": "^7.4.3", - "@types/diff": "^7.0.0", - "@types/get-value": "^3.0.5", - "@types/lodash.debounce": "^4.0.9", - "@types/node": "^25.0.3", - "@types/prismjs": "^1.26.5", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@types/react-redux": "^7.1.34", - "@types/react-resize-detector": "^4.0.3", - "@types/sha1": "^1.1.1", - "@types/socket.io-client": "^3.0.0", - "@types/uuid": "^11.0.0", - "@types/vis": "^4.21.24", - "chai": "^4.5.0", - "cross-env": "^7.0.3", - "css-loader": "^7.1.2", - "file-loader": "^6.2.0", - "html-webpack-plugin": "^5.6.3", + "@types/d3": "7.4.3", + "@types/diff": "7.0.0", + "@types/get-value": "3.0.5", + "@types/lodash.debounce": "4.0.9", + "@types/node": "25.0.3", + "@types/prismjs": "1.26.5", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@types/react-redux": "7.1.34", + "@types/sha1": "1.1.1", + "@types/socket.io-client": "3.0.0", + "@types/uuid": "11.0.0", + "@types/vis": "4.21.24", + "chai": "4.5.0", + "cross-env": "7.0.3", + "css-loader": "7.1.2", + "file-loader": "6.2.0", + "html-webpack-plugin": "5.6.3", "jsdom": "25.0.1", "jsdom-global": "3.0.2", - "lodash": "^4.17.23", - "mocha": "^10.8.2", - "moment": "^2.30.1", - "node-loader": "^2.0.0", - "source-map-loader": "^5.0.0", - "style-loader": "^4.0.0", - "ts-loader": "^9.5.1", - "typescript": "^5.9.3", - "webpack": "^5.98.0", - "webpack-bundle-analyzer": "^4.10.2", - "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.0" + "lodash": "4.17.23", + "mocha": "10.8.2", + "moment": "2.30.1", + "node-loader": "2.0.0", + "source-map-loader": "5.0.0", + "style-loader": "4.0.0", + "ts-loader": "9.5.1", + "typescript": "5.9.3", + "webpack": "5.98.0", + "webpack-bundle-analyzer": "4.10.2", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" }, "peerDependencies": { "electron": "^39" diff --git a/app/src/actions/Charts.ts b/app/src/actions/Charts.ts index cb036d3..cf71479 100644 --- a/app/src/actions/Charts.ts +++ b/app/src/actions/Charts.ts @@ -1,7 +1,7 @@ +import { Dispatch } from 'redux' import { Action, ActionTypes, ChartParameters } from '../reducers/Charts' import { AppState } from '../reducers' import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage' -import { Dispatch } from 'redux' import { showError, showNotification } from './Global' interface ConnectionViewState { @@ -16,7 +16,7 @@ const connectionViewStateIdentifier: StorageIdentifier async (dispatch: Dispatch, getState: () => AppState) => { - const connectionId = getState().connection.connectionId + const { connectionId } = getState().connection if (!connectionId) { return } @@ -40,7 +40,7 @@ export const loadCharts = () => async (dispatch: Dispatch, getState: () => } export const saveCharts = () => async (dispatch: Dispatch, getState: () => AppState) => { - const connectionId = getState().connection.connectionId + const { connectionId } = getState().connection if (!connectionId) { return } @@ -110,9 +110,7 @@ export const moveChartUp = dispatch(saveCharts()) } -export const setCharts = (charts: Array): Action => { - return { - charts, - type: ActionTypes.CHARTS_SET, - } -} +export const setCharts = (charts: Array): Action => ({ + charts, + type: ActionTypes.CHARTS_SET, +}) diff --git a/app/src/actions/Connection.ts b/app/src/actions/Connection.ts index 3ce67b8..c23eee2 100644 --- a/app/src/actions/Connection.ts +++ b/app/src/actions/Connection.ts @@ -1,10 +1,10 @@ -import * as q from '../../../backend/src/Model' import * as url from 'url' +import { DataSourceState, MqttOptions } from 'mqtt-explorer-backend/src/DataSource/DataSource' +import { Dispatch } from 'redux' +import * as q from '../../../backend/src/Model' import { Action, ActionTypes } from '../reducers/Connection' import { ActionTypes as SettingsActionTypes } from '../reducers/Settings' import { AppState } from '../reducers' -import { DataSourceState, MqttOptions } from '../../../backend/src/DataSource' -import { Dispatch } from 'redux' import { globalActions } from '.' import { resetStore as resetTreeStore, showTree } from './Tree' import { showError } from './Global' diff --git a/app/src/actions/ConnectionManager.ts b/app/src/actions/ConnectionManager.ts index 0475a7a..d3adf74 100644 --- a/app/src/actions/ConnectionManager.ts +++ b/app/src/actions/ConnectionManager.ts @@ -1,3 +1,7 @@ +import { Dispatch } from 'redux' +import * as path from 'path' +import { Subscription } from 'mqtt-explorer-backend/src/DataSource/MqttSource' +import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' import { AppState } from '../reducers' import { clearLegacyConnectionOptions, loadLegacyConnectionOptions } from '../model/LegacyConnectionSettings' import { @@ -7,14 +11,10 @@ import { CertificateParameters, } from '../model/ConnectionOptions' import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage' -import { Dispatch } from 'redux' import { showError } from './Global' -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 { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' export interface ConnectionDictionary { [s: string]: ConnectionOptions diff --git a/app/src/actions/Global.ts b/app/src/actions/Global.ts index ed03729..a6cbc18 100644 --- a/app/src/actions/Global.ts +++ b/app/src/actions/Global.ts @@ -1,5 +1,5 @@ -import { ActionTypes, ConfirmationRequest } from '../reducers/Global' import { Dispatch } from 'redux' +import { ActionTypes, ConfirmationRequest } from '../reducers/Global' export const showError = (error?: string | unknown) => ({ error, @@ -27,8 +27,8 @@ export const toggleAboutDialogVisibility = () => (dispatch: Dispatch) => { }) } -export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch) => { - return new Promise(resolve => { +export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch) => + new Promise(resolve => { const confirmationRequest = { title, inquiry, @@ -43,13 +43,11 @@ export const requestConfirmation = (title: string, inquiry: string) => (dispatch type: ActionTypes.requestConfirmation, }) }) -} -export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch) => { - return new Promise((resolve, reject) => { +export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch) => + new Promise((resolve, reject) => { dispatch({ confirmationRequest, type: ActionTypes.removeConfirmationRequest, }) }) -} diff --git a/app/src/actions/Publish.ts b/app/src/actions/Publish.ts index cd18fa5..b5bf046 100644 --- a/app/src/actions/Publish.ts +++ b/app/src/actions/Publish.ts @@ -1,18 +1,16 @@ +import { Dispatch } from 'redux' +import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' +import { Base64 } from 'js-base64' +import { Base64Message } from '../../../backend/src/Model/Base64Message' 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 { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' import { showError } from './Global' -import { Base64 } from 'js-base64' -export const setTopic = (topic?: string): Action => { - return { - topic, - type: ActionTypes.PUBLISH_SET_TOPIC, - } -} +export const setTopic = (topic?: string): Action => ({ + topic, + type: ActionTypes.PUBLISH_SET_TOPIC, +}) export const openFile = (encoding: BufferEncoding = 'utf8') => @@ -58,26 +56,20 @@ async function getFileContent(encoding: BufferEncoding): Promise { - return { - payload, - type: ActionTypes.PUBLISH_SET_PAYLOAD, - } -} +export const setPayload = (payload?: string): Action => ({ + payload, + type: ActionTypes.PUBLISH_SET_PAYLOAD, +}) -export const setQoS = (qos: 0 | 1 | 2): Action => { - return { - qos, - type: ActionTypes.PUBLISH_SET_QOS, - } -} +export const setQoS = (qos: 0 | 1 | 2): Action => ({ + qos, + type: ActionTypes.PUBLISH_SET_QOS, +}) -export const setEditorMode = (editorMode: string): Action => { - return { - editorMode, - type: ActionTypes.PUBLISH_SET_EDITOR_MODE, - } -} +export const setEditorMode = (editorMode: string): Action => ({ + editorMode, + type: ActionTypes.PUBLISH_SET_EDITOR_MODE, +}) export const publish = (connectionId: string) => (dispatch: Dispatch, getState: () => AppState) => { const state = getState() @@ -97,8 +89,6 @@ export const publish = (connectionId: string) => (dispatch: Dispatch, ge rendererEvents.emit(publishEvent, mqttMessage) } -export const toggleRetain = (): Action => { - return { - type: ActionTypes.PUBLISH_TOGGLE_RETAIN, - } -} +export const toggleRetain = (): Action => ({ + type: ActionTypes.PUBLISH_TOGGLE_RETAIN, +}) diff --git a/app/src/actions/Settings.ts b/app/src/actions/Settings.ts index 80282e6..2f8d564 100644 --- a/app/src/actions/Settings.ts +++ b/app/src/actions/Settings.ts @@ -1,12 +1,12 @@ +import { batchActions } from 'redux-batched-actions' +import { Dispatch } from 'redux' import * as q from '../../../backend/src/Model' +import { Base64Message } from '../../../backend/src/Model/Base64Message' import { ActionTypes, SettingsStateModel, TopicOrder, ValueRendererDisplayMode } from '../reducers/Settings' import { AppState } from '../reducers' import { autoExpandLimitSet } from '../components/SettingsDrawer/Settings' -import { Base64Message } from '../../../backend/src/Model/Base64Message' -import { batchActions } from 'redux-batched-actions' import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage' -import { Dispatch } from 'redux' -import { globalActions } from './' +import { globalActions } from '.' import { showError } from './Global' import { showTree } from './Tree' import { TopicViewModel } from '../model/TopicViewModel' diff --git a/app/src/actions/Sidebar.ts b/app/src/actions/Sidebar.ts index 18eec74..5bacbb4 100644 --- a/app/src/actions/Sidebar.ts +++ b/app/src/actions/Sidebar.ts @@ -1,7 +1,7 @@ +import { Dispatch } from 'redux' import * as q from '../../../backend/src/Model' import { ActionTypes } from '../reducers/Sidebar' import { AppState } from '../reducers' -import { Dispatch } from 'redux' import { clearTopic } from './clearTopic' export { clearTopic } from './clearTopic' diff --git a/app/src/actions/Tree.ts b/app/src/actions/Tree.ts index 5966bdd..0831a37 100644 --- a/app/src/actions/Tree.ts +++ b/app/src/actions/Tree.ts @@ -1,13 +1,14 @@ +import { AnyAction, Dispatch } from 'redux' +import { batchActions } from 'redux-batched-actions' +import debounce from 'lodash.debounce' import * as q from '../../../backend/src/Model' import { ActionTypes } from '../reducers/Tree' import { ActionTypes as SidebarActionTypes } from '../reducers/Sidebar' -import { AnyAction, Dispatch } from 'redux' import { AppState } from '../reducers' -import { batchActions } from 'redux-batched-actions' -import { globalActions } from './' +import { globalActions } from '.' import { setTopic } from './Publish' import { TopicViewModel } from '../model/TopicViewModel' -import debounce from 'lodash.debounce' + export { clearTopic } from './clearTopic' export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal' diff --git a/app/src/actions/UpdateNotifier.ts b/app/src/actions/UpdateNotifier.ts index ed8ad32..2c962b5 100644 --- a/app/src/actions/UpdateNotifier.ts +++ b/app/src/actions/UpdateNotifier.ts @@ -1,15 +1,11 @@ import { ActionTypes, GlobalAction } from '../reducers/Global' -export const showUpdateNotification = (show: boolean): GlobalAction => { - return { - type: ActionTypes.showUpdateNotification, - showUpdateNotification: show, - } -} +export const showUpdateNotification = (show: boolean): GlobalAction => ({ + type: ActionTypes.showUpdateNotification, + showUpdateNotification: show, +}) -export const showUpdateDetails = (show: boolean): GlobalAction => { - return { - type: ActionTypes.showUpdateDetails, - showUpdateDetails: show, - } -} +export const showUpdateDetails = (show: boolean): GlobalAction => ({ + type: ActionTypes.showUpdateDetails, + showUpdateDetails: show, +}) diff --git a/app/src/actions/clearTopic.ts b/app/src/actions/clearTopic.ts index a61d0f5..53634fb 100644 --- a/app/src/actions/clearTopic.ts +++ b/app/src/actions/clearTopic.ts @@ -1,6 +1,6 @@ +import { Dispatch } from 'redux' import * as q from '../../../backend/src/Model' import { AppState } from '../reducers' -import { Dispatch } from 'redux' import { makePublishEvent, rendererEvents } from '../eventBus' import { moveSelectionUpOrDownwards } from './visibleTreeTraversal' import { globalActions } from '.' @@ -45,7 +45,7 @@ export const clearTopic = topic: path, payload: null, retain: true, - qos: 0 as 0, + qos: 0 as const, messageId: undefined, } // Rate limit deletion diff --git a/app/src/actions/migrations/Connection.ts b/app/src/actions/migrations/Connection.ts index 9fc8209..0c454d7 100644 --- a/app/src/actions/migrations/Connection.ts +++ b/app/src/actions/migrations/Connection.ts @@ -21,7 +21,7 @@ export interface ConnectionOptionsV0 { subscriptions: Array } -let migrations: Migration[] = [ +const migrations: Migration[] = [ // iot.eclipse.org ha moved to mqtt.eclipse.org { from: undefined, @@ -60,13 +60,11 @@ let migrations: Migration[] = [ // Added QoS level to subscription options { from: undefined, - apply: (connection: ConnectionOptionsV0): ConnectionOptions => { - return { - ...connection, - configVersion: 1, - subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })), - } - }, + apply: (connection: ConnectionOptionsV0): ConnectionOptions => ({ + ...connection, + configVersion: 1, + subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })), + }), }, ] @@ -79,9 +77,9 @@ function isMigrationNecessary(connections: ConnectionDictionary): boolean { } function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary { - let newConnectionDictionary: ConnectionDictionary = {} + const newConnectionDictionary: ConnectionDictionary = {} Object.keys(connections).forEach(key => { - let newConnection = connectionMigrator.applyMigrations(connections[key]) as any + const newConnection = connectionMigrator.applyMigrations(connections[key]) as any newConnectionDictionary[newConnection.id] = newConnection }) diff --git a/app/src/actions/visibleTreeTraversal.ts b/app/src/actions/visibleTreeTraversal.ts index 63fb9f2..3c13082 100644 --- a/app/src/actions/visibleTreeTraversal.ts +++ b/app/src/actions/visibleTreeTraversal.ts @@ -1,6 +1,6 @@ +import { Dispatch } from 'redux' import * as q from '../../../backend/src/Model' import { AppState } from '../reducers' -import { Dispatch } from 'redux' import { selectTopic } from './Tree' import { SettingsState } from '../reducers/Settings' import { sortedNodes } from '../sortedNodes' @@ -69,9 +69,8 @@ function nextVisibleElementInTree( ): q.TreeNode | undefined { if (direction === 'next') { return findNextNodeDownward(settings, node) - } else { - return findNextNodeUpward(settings, node) } + return findNextNodeUpward(settings, node) } /** Not very efficient but easy to implement, complexity should not be an issue here */ @@ -92,9 +91,8 @@ function findNextNodeUpward( const upwardNeighbor = neighborNodes[nodeIdx - 1] if (upwardNeighbor) { return lastVisibleChild(settings, upwardNeighbor) - } else { - return findNextNodeUpward(settings, parent) } + return findNextNodeUpward(settings, parent) } function lastVisibleChild(settings: SettingsState, treeNode: q.TreeNode): q.TreeNode { @@ -132,7 +130,6 @@ function findNextNodeDownwardNeighbor( const downwardNeighbor = neighborNodes[nodeIdx + 1] if (downwardNeighbor) { return downwardNeighbor - } else { - return findNextNodeDownwardNeighbor(settings, parent) } + return findNextNodeDownwardNeighbor(settings, parent) } diff --git a/app/src/autoConnectHandler.ts b/app/src/autoConnectHandler.ts index 28e3917..b8fffe3 100644 --- a/app/src/autoConnectHandler.ts +++ b/app/src/autoConnectHandler.ts @@ -1,31 +1,31 @@ // 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 { DataSourceState } from 'mqtt-explorer-backend/src/DataSource/DataSource' +import { store } from './store' 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() diff --git a/app/src/browserEventBus.ts b/app/src/browserEventBus.ts index 09a846e..c87fcfe 100644 --- a/app/src/browserEventBus.ts +++ b/app/src/browserEventBus.ts @@ -23,76 +23,105 @@ const socket: Socket = io({ }) // Handle connection errors -socket.on('connect_error', (error) => { +socket.on('connect_error', error => { console.error('Socket connection error:', error.message) - + // Check if it's an authentication error - if (error.message.includes('Invalid credentials') || - error.message.includes('Authentication required') || - error.message.includes('Too many')) { + if ( + error.message.includes('Invalid credentials') || + error.message.includes('Authentication required') || + error.message.includes('Too many') + ) { // Clear invalid credentials from sessionStorage if (typeof sessionStorage !== 'undefined') { sessionStorage.removeItem('mqtt-explorer-username') sessionStorage.removeItem('mqtt-explorer-password') } - + // Dispatch custom event that BrowserAuthWrapper can listen to if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('mqtt-auth-error', { - detail: { message: error.message } - })) + window.dispatchEvent( + new CustomEvent('mqtt-auth-error', { + detail: { message: error.message }, + }) + ) } } }) -socket.on('disconnect', (reason) => { +socket.on('disconnect', reason => { console.log('Socket disconnected:', reason) }) socket.on('connect', () => { console.log('Socket connected successfully') - + // Dispatch custom event that BrowserAuthWrapper can listen to if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('mqtt-auth-success', { - detail: { message: 'Authentication successful' } - })) + window.dispatchEvent( + new CustomEvent('mqtt-auth-success', { + detail: { message: 'Authentication successful' }, + }) + ) } }) // Listen for auth-status from server (sent on connection) socket.on('auth-status', (data: { authDisabled: boolean }) => { console.log('Auth status received from server:', data) - + // Dispatch custom event with auth status if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('mqtt-auth-status', { - detail: { authDisabled: data.authDisabled } - })) + window.dispatchEvent( + new CustomEvent('mqtt-auth-status', { + detail: { authDisabled: data.authDisabled }, + }) + ) } }) // Listen for auto-connect configuration from server socket.on('auto-connect-config', (config: any) => { console.log('Auto-connect configuration received from server') - + // Dispatch custom event with auto-connect config if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('mqtt-auto-connect-config', { - detail: config - })) + 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 - })) + window.dispatchEvent( + new CustomEvent('mqtt-auto-connect-initiated', { + detail: data, + }) + ) + } +}) + +// Listen for LLM availability from server (new architecture) +socket.on('llm-available', (data: { available: boolean }) => { + console.log('LLM availability received from server:', data.available) + + // Store availability flag in window object + if (typeof window !== 'undefined') { + window.__llmAvailable = data.available + + // Dispatch custom event for components to react + window.dispatchEvent( + new CustomEvent('llm-availability-changed', { + detail: { available: data.available }, + }) + ) } }) @@ -104,19 +133,19 @@ socket.on('auto-connect-initiated', (data: { connectionId: string }) => { export function updateSocketAuth(newUsername: string, newPassword: string) { username = newUsername password = newPassword - + // Update socket auth socket.auth = { username: newUsername, password: newPassword, } - + // Store in sessionStorage if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem('mqtt-explorer-username', newUsername) sessionStorage.setItem('mqtt-explorer-password', newPassword) } - + // Disconnect if connected, then reconnect with new credentials if (socket.connected) { socket.disconnect() diff --git a/app/src/components/AboutDialog.spec.ts b/app/src/components/AboutDialog.spec.ts index 109b7b3..30e36c8 100644 --- a/app/src/components/AboutDialog.spec.ts +++ b/app/src/components/AboutDialog.spec.ts @@ -5,10 +5,10 @@ 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 @@ -48,45 +48,45 @@ describe('AboutDialog License Compliance', () => { // 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.' + '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.' + '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.' + '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 }) }) @@ -94,39 +94,39 @@ describe('AboutDialog License Compliance', () => { /** * 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/) }) diff --git a/app/src/components/AboutDialog.tsx b/app/src/components/AboutDialog.tsx index 24acdc2..2838f81 100644 --- a/app/src/components/AboutDialog.tsx +++ b/app/src/components/AboutDialog.tsx @@ -11,8 +11,8 @@ import { Box, Divider, } from '@mui/material' -import { rendererRpc, getAppVersion } from '../../../events' import FavoriteIcon from '@mui/icons-material/Favorite' +import { rendererRpc, getAppVersion } from '../eventBus' // Fallback version if RPC call fails (e.g., in browser mode during initialization) const FALLBACK_VERSION = '0.4.0-beta.5' @@ -24,20 +24,20 @@ interface AboutDialogProps { /** * 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 */ @@ -68,15 +68,19 @@ export function AboutDialog(props: AboutDialogProps) { Description: Explore your message queues - + - - - + + + Thomas Nordquist diff --git a/app/src/components/App.tsx b/app/src/components/App.tsx index 5f22ce0..4c36f04 100644 --- a/app/src/components/App.tsx +++ b/app/src/components/App.tsx @@ -1,19 +1,19 @@ +import CssBaseline from '@mui/material/CssBaseline' +import React from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { Theme } from '@mui/material/styles' +import { withStyles } from '@mui/styles' import ConfirmationDialog from './ConfirmationDialog' import ConnectionSetup from './ConnectionSetup/ConnectionSetup' -import CssBaseline from '@mui/material/CssBaseline' import ErrorBoundary from './ErrorBoundary' 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' -import { connect } from 'react-redux' import { globalActions, settingsActions } from '../actions' -import { Theme } from '@mui/material/styles' -import { withStyles } from '@mui/styles' ;(window as any).global = window const Settings = React.lazy(() => import('./SettingsDrawer/Settings')) @@ -82,7 +82,7 @@ class App extends React.PureComponent { onClose={() => this.props.actions.toggleAboutDialogVisibility()} /> {this.renderNotification()} - }> + }>
@@ -90,7 +90,7 @@ class App extends React.PureComponent {
-
}> + }> { paneDefaults: { backgroundColor: theme.palette.background.default, color: theme.palette.text.primary, - display: 'block' as 'block', + display: 'block' as const, height: 'calc(100vh - 64px)', }, centerContent: { width: '100vw', - overflow: 'hidden' as 'hidden', + overflow: 'hidden' as const, }, content: { ...contentBaseStyle, @@ -148,24 +148,20 @@ const styles = (theme: Theme) => { } } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: bindActionCreators(globalActions, dispatch), - settingsActions: bindActionCreators(settingsActions, dispatch), - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: bindActionCreators(globalActions, dispatch), + settingsActions: bindActionCreators(settingsActions, dispatch), +}) -const mapStateToProps = (state: AppState) => { - return { - settingsVisible: state.globalState.get('settingsVisible'), - connectionId: state.connection.connectionId, - error: state.globalState.get('error'), - notification: state.globalState.get('notification'), - highlightTopicUpdates: state.settings.get('highlightTopicUpdates'), - launching: state.globalState.get('launching'), - confirmationRequests: state.globalState.get('confirmationRequests'), - aboutDialogVisible: state.globalState.get('aboutDialogVisible'), - } -} +const mapStateToProps = (state: AppState) => ({ + settingsVisible: state.globalState.get('settingsVisible'), + connectionId: state.connection.connectionId, + error: state.globalState.get('error'), + notification: state.globalState.get('notification'), + highlightTopicUpdates: state.settings.get('highlightTopicUpdates'), + launching: state.globalState.get('launching'), + confirmationRequests: state.globalState.get('confirmationRequests'), + aboutDialogVisible: state.globalState.get('aboutDialogVisible'), +}) export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(App)) diff --git a/app/src/components/BrowserAuthWrapper.tsx b/app/src/components/BrowserAuthWrapper.tsx index 7f4b6f6..b3d9a56 100644 --- a/app/src/components/BrowserAuthWrapper.tsx +++ b/app/src/components/BrowserAuthWrapper.tsx @@ -29,7 +29,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) { const handleAuthStatus = (event: CustomEvent) => { const { authDisabled } = event.detail setAuthDisabled(authDisabled) - + if (authDisabled) { // Authentication is disabled on server console.log('Authentication is disabled on server, skipping login') @@ -39,7 +39,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) { } else { // Authentication is enabled, check if we have credentials setAuthCheckComplete(true) - + const username = sessionStorage.getItem('mqtt-explorer-username') const password = sessionStorage.getItem('mqtt-explorer-password') @@ -67,15 +67,15 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) { const handleAuthError = (event: CustomEvent) => { const errorMessage = event.detail?.message || 'Authentication failed' console.error('Authentication error:', errorMessage) - + // Mark auth check as complete - we now know auth is required setAuthCheckComplete(true) - + // Clear authentication state setIsAuthenticated(false) setShowLogin(true) setIsConnecting(false) - + // Extract wait time from error message (e.g., "Please wait 30 seconds") const waitTimeMatch = errorMessage.match(/(\d+)\s+seconds?/) if (waitTimeMatch) { @@ -85,7 +85,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) { } else { setWaitTimeSeconds(undefined) } - + // Set user-friendly error message based on error type // Error messages from server already include wait times if (errorMessage.includes('Too many failed authentication attempts')) { @@ -121,7 +121,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) { setLoginError(undefined) setWaitTimeSeconds(undefined) setIsConnecting(true) - + // Update socket auth and reconnect (no page reload needed) updateSocketAuth(username, password) } catch (error) { diff --git a/app/src/components/Chart/Chart.domain.spec.tsx b/app/src/components/Chart/Chart.domain.spec.tsx index c2093b6..0e6938d 100644 --- a/app/src/components/Chart/Chart.domain.spec.tsx +++ b/app/src/components/Chart/Chart.domain.spec.tsx @@ -11,11 +11,11 @@ describe('Chart X-Axis Domain Investigation', () => { { x: now - 3000, y: 21 }, { x: now - 2000, y: 22 }, { x: now - 1000, y: 23 }, - { x: now, y: 24 } + { x: now, y: 24 }, ] const { container } = renderWithProviders() - + // Find all circle elements (data points) const circles = container.querySelectorAll('svg circle') expect(circles).to.have.length(5) @@ -33,26 +33,28 @@ describe('Chart X-Axis Domain Investigation', () => { console.log('\n========== X-AXIS DOMAIN INVESTIGATION ==========') console.log('Data X values (timestamps):') data.forEach((d, i) => console.log(` Point ${i}: ${d.x} (${new Date(d.x).toISOString()})`)) - console.log(`\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)`) - + console.log( + `\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)` + ) + console.log('\nRendered circle CX positions:') cxValues.forEach((cx, i) => console.log(` Circle ${i}: cx=${cx.toFixed(2)}px`)) - + const minCx = Math.min(...cxValues) const maxCx = Math.max(...cxValues) const cxRange = maxCx - minCx - + console.log(`\nCX position range: ${cxRange.toFixed(2)}px (from ${minCx.toFixed(2)} to ${maxCx.toFixed(2)})`) console.log(`Points per pixel: ${(cxValues.length / cxRange).toFixed(4)}`) - + // Calculate spacing between consecutive points const spacings: number[] = [] for (let i = 1; i < cxValues.length; i++) { spacings.push(cxValues[i] - cxValues[i - 1]) } console.log('\nSpacing between consecutive points:') - spacings.forEach((s, i) => console.log(` ${i} to ${i+1}: ${s.toFixed(2)}px`)) - + spacings.forEach((s, i) => console.log(` ${i} to ${i + 1}: ${s.toFixed(2)}px`)) + const avgSpacing = spacings.reduce((a, b) => a + b, 0) / spacings.length console.log(`Average spacing: ${avgSpacing.toFixed(2)}px`) console.log('=================================================\n') @@ -60,18 +62,19 @@ describe('Chart X-Axis Domain Investigation', () => { // Assertions: // 1. Points should be spread out (CX range should be significant, not bunched) expect(cxRange).to.be.greaterThan(50, 'Points should be spread across at least 50px') - + // 2. Points should be in ascending order (left to right) for (let i = 1; i < cxValues.length; i++) { - expect(cxValues[i]).to.be.greaterThan(cxValues[i - 1], - `Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i-1} (cx=${cxValues[i-1]})`) + expect(cxValues[i]).to.be.greaterThan( + cxValues[i - 1], + `Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i - 1} (cx=${cxValues[i - 1]})` + ) } - + // 3. Spacing should be relatively uniform (since data points are equally spaced in time) const spacingVariance = spacings.map(s => Math.abs(s - avgSpacing)) const maxVariance = Math.max(...spacingVariance) - expect(maxVariance).to.be.lessThan(avgSpacing * 0.5, - 'Spacing between points should be relatively uniform') + expect(maxVariance).to.be.lessThan(avgSpacing * 0.5, 'Spacing between points should be relatively uniform') }) it('should handle points bunched at far right correctly', () => { @@ -82,11 +85,11 @@ describe('Chart X-Axis Domain Investigation', () => { { x: largeTimestamp + 1000, y: 21 }, { x: largeTimestamp + 2000, y: 22 }, { x: largeTimestamp + 3000, y: 23 }, - { x: largeTimestamp + 4000, y: 24 } + { x: largeTimestamp + 4000, y: 24 }, ] const { container } = renderWithProviders() - + const circles = container.querySelectorAll('svg circle') const cxValues: number[] = [] circles.forEach(circle => { @@ -97,16 +100,21 @@ describe('Chart X-Axis Domain Investigation', () => { }) console.log('\n========== LARGE TIMESTAMP TEST ==========') - console.log('Data X values:', data.map(d => d.x)) - console.log('Rendered CX values:', cxValues.map(v => v.toFixed(2))) - + console.log( + 'Data X values:', + data.map(d => d.x) + ) + console.log( + 'Rendered CX values:', + cxValues.map(v => v.toFixed(2)) + ) + const minCx = Math.min(...cxValues) const maxCx = Math.max(...cxValues) console.log(`CX range: ${(maxCx - minCx).toFixed(2)}px`) console.log('==========================================\n') // Points should still be spread out even with large timestamps - expect(maxCx - minCx).to.be.greaterThan(50, - 'Points with large timestamps should still be spread across the chart') + expect(maxCx - minCx).to.be.greaterThan(50, 'Points with large timestamps should still be spread across the chart') }) }) diff --git a/app/src/components/Chart/Chart.spec.tsx b/app/src/components/Chart/Chart.spec.tsx index 7b37455..3cefb7d 100644 --- a/app/src/components/Chart/Chart.spec.tsx +++ b/app/src/components/Chart/Chart.spec.tsx @@ -1,6 +1,6 @@ /** * Chart Component Tests - * + * * These tests verify the Chart component functionality including: * - Rendering with various data configurations * - Theme integration @@ -22,14 +22,14 @@ describe('Chart Component', () => { it('should render without crashing with valid data', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + expect(container).to.exist expect(container.querySelector('svg')).to.exist }) it('should render NoData component when data is empty', () => { const { container } = renderWithProviders(, { withTheme: true }) - + expect(container).to.exist // NoData component should be rendered const noDataElement = container.querySelector('div') @@ -39,7 +39,7 @@ describe('Chart Component', () => { it('should render chart with correct height', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + const chartContainer = container.querySelector('[style*="height"]') as HTMLElement expect(chartContainer).to.exist expect(chartContainer.style.height).to.equal('150px') @@ -48,11 +48,11 @@ describe('Chart Component', () => { it('should render SVG chart elements', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + // Check for SVG element const svg = container.querySelector('svg') expect(svg).to.exist - + // Check for chart elements (paths for line series) const paths = container.querySelectorAll('path') expect(paths.length).to.be.greaterThan(0) @@ -63,7 +63,7 @@ describe('Chart Component', () => { it('should render data points as glyphs', () => { const data = createMockChartData(3) const { container } = renderWithProviders(, { withTheme: true }) - + // Check for circles (glyphs representing data points) const circles = container.querySelectorAll('circle') expect(circles.length).to.be.greaterThan(0) @@ -73,11 +73,11 @@ describe('Chart Component', () => { const dataLength = 5 const data = createMockChartData(dataLength) const { container } = renderWithProviders(, { withTheme: true }) - + // Each data point should render as a circle const circles = container.querySelectorAll('circle') expect(circles.length).to.equal(dataLength, `Expected ${dataLength} circles for ${dataLength} data points`) - + // Verify each circle has proper attributes circles.forEach((circle, index) => { expect(circle.getAttribute('cx')).to.exist @@ -90,18 +90,18 @@ describe('Chart Component', () => { it('should position data points with valid coordinates', () => { const data = createMockChartData(3) const { container } = renderWithProviders(, { withTheme: true }) - + const circles = container.querySelectorAll('circle') - circles.forEach((circle) => { + circles.forEach(circle => { const cx = parseFloat(circle.getAttribute('cx') || '0') const cy = parseFloat(circle.getAttribute('cy') || '0') - + // Coordinates should be valid numbers expect(cx).to.be.a('number') expect(cy).to.be.a('number') expect(isNaN(cx)).to.be.false expect(isNaN(cy)).to.be.false - + // Coordinates should be within chart bounds (positive values) expect(cx).to.be.greaterThan(0) expect(cy).to.be.greaterThan(0) @@ -111,7 +111,7 @@ describe('Chart Component', () => { it('should render line connecting data points', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + // Line series should create a path element const paths = container.querySelectorAll('path') expect(paths.length).to.be.greaterThan(0) @@ -120,7 +120,7 @@ describe('Chart Component', () => { it('should handle single data point', () => { const data = [{ x: Date.now(), y: 50 }] const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist const circles = container.querySelectorAll('circle') expect(circles.length).to.equal(1, 'Single data point should render as one circle') @@ -129,7 +129,7 @@ describe('Chart Component', () => { it('should handle large datasets', () => { const data = createMockChartData(100) const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist const circles = container.querySelectorAll('circle') expect(circles.length).to.equal(100, '100 data points should render as 100 circles') @@ -139,14 +139,13 @@ describe('Chart Component', () => { describe('Curve Interpolation', () => { const curveTypes: PlotCurveTypes[] = ['curve', 'linear', 'cubic_basis_spline', 'step_after', 'step_before'] - curveTypes.forEach((interpolation) => { + curveTypes.forEach(interpolation => { it(`should render with ${interpolation} interpolation`, () => { const data = createMockChartData(5) - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { + withTheme: true, + }) + expect(container.querySelector('svg')).to.exist const paths = container.querySelectorAll('path') expect(paths.length).to.be.greaterThan(0) @@ -158,11 +157,8 @@ describe('Chart Component', () => { it('should apply custom color', () => { const data = createMockChartData(5) const customColor = '#ff0000' - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { withTheme: true }) + // Check if custom color is applied to line or glyphs const svg = container.querySelector('svg') expect(svg).to.exist @@ -171,7 +167,7 @@ describe('Chart Component', () => { it('should use theme colors when no custom color provided', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) }) @@ -180,44 +176,34 @@ describe('Chart Component', () => { it('should render with custom Y range', () => { const data = createMockChartData(5) const range: [number, number] = [0, 100] - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { withTheme: true }) + expect(container.querySelector('svg')).to.exist }) it('should render with custom time range', () => { const data = createMockChartData(5) const timeRangeStart = 60000 // 1 minute - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { + withTheme: true, + }) + expect(container.querySelector('svg')).to.exist }) it('should render with partial Y range (only min)', () => { const data = createMockChartData(5) const range: [number?, number?] = [0, undefined] - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { withTheme: true }) + expect(container.querySelector('svg')).to.exist }) it('should render with partial Y range (only max)', () => { const data = createMockChartData(5) const range: [number?, number?] = [undefined, 100] - const { container } = renderWithProviders( - , - { withTheme: true } - ) - + const { container } = renderWithProviders(, { withTheme: true }) + expect(container.querySelector('svg')).to.exist }) }) @@ -226,11 +212,11 @@ describe('Chart Component', () => { it('should render Y-axis', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + // Y-axis should be present (look for axis group or tick marks) const svg = container.querySelector('svg') expect(svg).to.exist - + // Axis typically contains text elements for labels const texts = container.querySelectorAll('text') expect(texts.length).to.be.greaterThan(0) @@ -239,18 +225,18 @@ describe('Chart Component', () => { it('should render X-axis with time labels', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + // X-axis should be present with text labels const svg = container.querySelector('svg') expect(svg).to.exist - + // X-axis has text labels for timestamps const texts = container.querySelectorAll('text') expect(texts.length).to.be.greaterThan(0, 'X-axis and Y-axis should have text labels') - + // At least one text element should contain time format (e.g., contains ":") let hasTimeFormat = false - texts.forEach((text) => { + texts.forEach(text => { if (text.textContent && text.textContent.includes(':')) { hasTimeFormat = true } @@ -261,14 +247,14 @@ describe('Chart Component', () => { it('should render both X and Y axes', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + const svg = container.querySelector('svg') expect(svg).to.exist - + // Both axes should render tick marks (lines) const lines = container.querySelectorAll('line') expect(lines.length).to.be.greaterThan(0, 'Axes should render tick marks') - + // Both axes should have labels (text) const texts = container.querySelectorAll('text') expect(texts.length).to.be.greaterThan(2, 'Both axes should have multiple labels') @@ -277,7 +263,7 @@ describe('Chart Component', () => { it('should render grid lines', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + // Grid lines are rendered as line elements const svg = container.querySelector('svg') expect(svg).to.exist @@ -286,10 +272,10 @@ describe('Chart Component', () => { it('should have proper chart margins', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + const svg = container.querySelector('svg') expect(svg).to.exist - + // SVG should have proper dimensions expect(svg?.getAttribute('width')).to.exist expect(svg?.getAttribute('height')).to.exist @@ -304,7 +290,7 @@ describe('Chart Component', () => { { x: Date.now(), y: -75 }, ] const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) @@ -315,7 +301,7 @@ describe('Chart Component', () => { { x: Date.now(), y: 0 }, ] const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) @@ -326,7 +312,7 @@ describe('Chart Component', () => { { x: Date.now(), y: 3000000 }, ] const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist // Y-axis should abbreviate large numbers const texts = container.querySelectorAll('text') @@ -340,7 +326,7 @@ describe('Chart Component', () => { { x: Date.now(), y: 50 }, ] const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) }) @@ -355,7 +341,7 @@ describe('Chart Component', () => { timeRangeStart: 60000, color: '#00ff00', } - + const { container } = renderWithProviders(, { withTheme: true }) expect(container.querySelector('svg')).to.exist }) @@ -363,7 +349,7 @@ describe('Chart Component', () => { it('should work with minimal props', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) }) @@ -372,7 +358,7 @@ describe('Chart Component', () => { it('should render in light theme', () => { const data = createMockChartData(5) const { container } = renderWithProviders(, { withTheme: true }) - + expect(container.querySelector('svg')).to.exist }) @@ -384,7 +370,7 @@ describe('Chart Component', () => { it('should memoize component with same props', () => { const data = createMockChartData(5) const { rerender } = renderWithProviders(, { withTheme: true }) - + // Component should not re-render with same props due to React.memo expect(() => { rerender() @@ -392,16 +378,13 @@ describe('Chart Component', () => { }) it('should handle rapid data updates', () => { - const { rerender, container } = renderWithProviders( - , - { withTheme: true } - ) - + const { rerender, container } = renderWithProviders(, { withTheme: true }) + // Simulate rapid updates for (let i = 0; i < 10; i++) { rerender() } - + expect(container.querySelector('svg')).to.exist }) }) @@ -410,43 +393,40 @@ describe('Chart Component', () => { it('should dynamically update when data points are added', () => { // Start with 3 data points const initialData = createMockChartData(3) - const { rerender, container } = renderWithProviders( - , - { withTheme: true } - ) - + const { rerender, container } = renderWithProviders(, { withTheme: true }) + // Verify initial state: should have 3 data points const initialCircles = container.querySelectorAll('circle') expect(initialCircles.length).to.equal(3, 'Should initially render 3 data points') - + // Verify each initial circle has valid attributes initialCircles.forEach((circle, index) => { const cx = circle.getAttribute('cx') const cy = circle.getAttribute('cy') const r = circle.getAttribute('r') - + expect(cx).to.exist expect(cy).to.exist expect(r).to.equal('3') expect(parseFloat(cx!)).to.be.a('number').and.not.NaN expect(parseFloat(cy!)).to.be.a('number').and.not.NaN }) - + // Update state: add 2 more data points (total 5) const updatedData = createMockChartData(5) rerender() - + // Verify updated state: should now have 5 data points const updatedCircles = container.querySelectorAll('circle') expect(updatedCircles.length).to.equal(5, 'Should render 5 data points after update') - + // Verify each updated circle has valid attributes updatedCircles.forEach((circle, index) => { const cx = circle.getAttribute('cx') const cy = circle.getAttribute('cy') const r = circle.getAttribute('r') const fill = circle.getAttribute('fill') - + expect(cx).to.exist expect(cy).to.exist expect(r).to.equal('3') @@ -455,12 +435,12 @@ describe('Chart Component', () => { expect(parseFloat(cy!)).to.be.a('number').and.not.NaN expect(parseFloat(cy!)).to.be.greaterThan(0, 'Y coordinate should be positive') }) - + // Verify the line path is updated to connect all 5 points const linePath = container.querySelector('path[stroke]') expect(linePath).to.exist expect(linePath!.getAttribute('d')).to.exist - + // The path should start with MoveTo (M) command and contain curve/line commands const pathData = linePath!.getAttribute('d') expect(pathData).to.include('M') // MoveTo command for first point @@ -471,19 +451,16 @@ describe('Chart Component', () => { it('should handle data point removal', () => { // Start with 5 data points const initialData = createMockChartData(5) - const { rerender, container } = renderWithProviders( - , - { withTheme: true } - ) - + const { rerender, container } = renderWithProviders(, { withTheme: true }) + // Verify initial state let circles = container.querySelectorAll('circle') expect(circles.length).to.equal(5, 'Should initially render 5 data points') - + // Remove 2 data points (now 3) const reducedData = createMockChartData(3) rerender() - + // Verify reduced state circles = container.querySelectorAll('circle') expect(circles.length).to.equal(3, 'Should render 3 data points after removal') @@ -491,20 +468,17 @@ describe('Chart Component', () => { it('should maintain chart structure during data updates', () => { const initialData = createMockChartData(3) - const { rerender, container } = renderWithProviders( - , - { withTheme: true } - ) - + const { rerender, container } = renderWithProviders(, { withTheme: true }) + // Verify chart structure exists initially expect(container.querySelector('svg')).to.exist expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should have axis/grid lines') expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should have axis labels') - + // Update data const updatedData = createMockChartData(5) rerender() - + // Verify chart structure is maintained after update expect(container.querySelector('svg')).to.exist expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should still have axis/grid lines') diff --git a/app/src/components/Chart/Chart.tsx b/app/src/components/Chart/Chart.tsx index f93310e..da57f32 100644 --- a/app/src/components/Chart/Chart.tsx +++ b/app/src/components/Chart/Chart.tsx @@ -1,16 +1,17 @@ +import React, { memo, useCallback, useMemo } from 'react' +import { useResizeDetector } from 'react-resize-detector' +import { emphasize, useTheme } from '@mui/material/styles' +import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart' import DateFormatter from '../helper/DateFormatter' import NoData from './NoData' import NumberFormatter from '../helper/NumberFormatter' -import React, { memo, useCallback, useMemo } from 'react' import TooltipComponent from './TooltipComponent' -import { useResizeDetector } from 'react-resize-detector' -import { emphasize, useTheme } from '@mui/material/styles' import { mapCurveType } from './mapCurveType' import { PlotCurveTypes } from '../../reducers/Charts' import { Point, Tooltip } from './Model' import { useCustomXDomain } from './effects/useCustomXDomain' import { useCustomYDomain } from './effects/useCustomYDomain' -import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart' + const abbreviate = require('number-abbreviate') export interface Props { @@ -32,7 +33,7 @@ export default memo((props: Props) => { const hintFormatter = React.useCallback( (point: any) => [ - { title: Time, value: }, + { title: Time, value: }, { title: Value, value: }, { title: Raw, value: {point.y} }, ], @@ -55,8 +56,7 @@ export default memo((props: Props) => { [hintFormatter] ) - const paletteColor = - theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light + const paletteColor = theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light const color = props.color ? props.color : paletteColor const highlightSelectedPoint = useCallback( @@ -68,7 +68,7 @@ export default memo((props: Props) => { ) const formatYAxis = useCallback((num: number) => abbreviate(num), []) - + const formatXAxis = useCallback((timestamp: number) => { const date = new Date(timestamp) const hours = date.getHours().toString().padStart(2, '0') @@ -80,7 +80,7 @@ export default memo((props: Props) => { const xDomain = useCustomXDomain(props) const yDomain = useCustomYDomain(props) - const data = props.data + const { data } = props const hasData = data.length > 0 const dummyDomain: [number, number] = [-1, 1] const dummyData = [{ x: -2, y: -2 }] @@ -101,25 +101,30 @@ export default memo((props: Props) => { - - + ({ fontSize: 11, fill: theme.palette.text.secondary })} /> - ({ fontSize: 10, fill: theme.palette.text.secondary, textAnchor: 'middle' })} /> @@ -131,7 +136,7 @@ export default memo((props: Props) => { stroke={color} strokeWidth={2} curve={mapCurveType(props.interpolation)} - onPointerMove={(datum) => { + onPointerMove={datum => { if (datum && datum.datum) { const point = datum.datum as Point showTooltip(point) @@ -143,17 +148,10 @@ export default memo((props: Props) => { data={hasData ? data : dummyData} xAccessor={accessors.xAccessor} yAccessor={accessors.yAccessor} - renderGlyph={(glyphProps) => { + renderGlyph={glyphProps => { const point = glyphProps.datum as Point const pointColor = highlightSelectedPoint(point) - return ( - - ) + return }} /> diff --git a/app/src/components/Chart/TooltipComponent.tsx b/app/src/components/Chart/TooltipComponent.tsx index bc3bbaf..d77d3ba 100644 --- a/app/src/components/Chart/TooltipComponent.tsx +++ b/app/src/components/Chart/TooltipComponent.tsx @@ -8,9 +8,9 @@ function TooltipComponent(props: { tooltip?: Tooltip }) { const { tooltip } = props return ( @@ -27,9 +27,7 @@ function TooltipComponent(props: { tooltip?: Tooltip }) { padding: '4px', marginTop: '-12px', backgroundColor: fade( - theme.palette.mode === 'light' - ? theme.palette.background.paper - : theme.palette.background.default, + theme.palette.mode === 'light' ? theme.palette.background.paper : theme.palette.background.default, 0.7 ), }} diff --git a/app/src/components/Chart/effects/useCustomXDomain.tsx b/app/src/components/Chart/effects/useCustomXDomain.tsx index c3305f1..f01885c 100644 --- a/app/src/components/Chart/effects/useCustomXDomain.tsx +++ b/app/src/components/Chart/effects/useCustomXDomain.tsx @@ -9,16 +9,15 @@ export function useCustomXDomain(props: Props): [number, number] | undefined { const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0] const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now() - + if (props.timeRangeStart) { // Custom time range mode return [Date.now() - props.timeRangeStart, lastDataDate] - } else { - // Auto-calculate from data (like react-vis did) - const xValues = props.data.map(d => d.x) - const minX = Math.min(...xValues) - const maxX = Math.max(...xValues) - return [minX, maxX] } + // Auto-calculate from data (like react-vis did) + const xValues = props.data.map(d => d.x) + const minX = Math.min(...xValues) + const maxX = Math.max(...xValues) + return [minX, maxX] }, [props.data, props.timeRangeStart]) } diff --git a/app/src/components/Chart/effects/useCustomYDomain.tsx b/app/src/components/Chart/effects/useCustomYDomain.tsx index dfabe86..69b5524 100644 --- a/app/src/components/Chart/effects/useCustomYDomain.tsx +++ b/app/src/components/Chart/effects/useCustomYDomain.tsx @@ -1,5 +1,5 @@ -import { Props } from '../Chart' import { useMemo } from 'react' +import { Props } from '../Chart' import { Point } from '../Model' function defaultFor(a: number | undefined, b: number) { @@ -8,7 +8,7 @@ function defaultFor(a: number | undefined, b: number) { export function useCustomYDomain(props: Props) { return useMemo(() => { - const data = props.data + const { data } = props const calculatedDomain = domainForData(data) const yDomain: [number, number] = props.range ? [defaultFor(props.range[0], calculatedDomain[0]), defaultFor(props.range[1], calculatedDomain[1])] diff --git a/app/src/components/Chart/mapCurveType.tsx b/app/src/components/Chart/mapCurveType.tsx index f9c301c..924be4a 100644 --- a/app/src/components/Chart/mapCurveType.tsx +++ b/app/src/components/Chart/mapCurveType.tsx @@ -1,5 +1,5 @@ -import { PlotCurveTypes } from '../../reducers/Charts' import * as d3Shape from 'd3-shape' +import { PlotCurveTypes } from '../../reducers/Charts' export function mapCurveType(type: PlotCurveTypes | undefined) { switch (type) { diff --git a/app/src/components/ChartPanel/ChartSettings/ColorSettings.tsx b/app/src/components/ChartPanel/ChartSettings/ColorSettings.tsx index 5e6db5c..6427cca 100644 --- a/app/src/components/ChartPanel/ChartSettings/ColorSettings.tsx +++ b/app/src/components/ChartPanel/ChartSettings/ColorSettings.tsx @@ -1,9 +1,9 @@ import React, { memo } from 'react' import { bindActionCreators } from 'redux' -import { chartActions } from '../../../actions' -import { ChartParameters } from '../../../reducers/Charts' import { connect } from 'react-redux' import { Menu, MenuItem } from '@mui/material' +import { chartActions } from '../../../actions' +import { ChartParameters } from '../../../reducers/Charts' import { colors as createColors } from './colors' function chartParametersForColor(chart: ChartParameters, color?: string) { @@ -30,17 +30,24 @@ function ColorSettings(props: { [props.chart] ) - const menuItems = React.useMemo(() => { - return colors.map(color => ( - setColor(color)} - > - {props.chart.color === color ? 'X' : ''} - - )) - }, [colors, props.chart]) + const menuItems = React.useMemo( + () => + colors.map(color => ( + setColor(color)} + > + {props.chart.color === color ? 'X' : ''} + + )), + [colors, props.chart] + ) return ( @@ -57,12 +64,10 @@ function ColorSettings(props: { ) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(memo(ColorSettings)) diff --git a/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx b/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx index b88aa15..94768e0 100644 --- a/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx +++ b/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx @@ -1,10 +1,10 @@ import * as React from 'react' -import { AppState } from '../../../reducers' import { bindActionCreators } from 'redux' -import { chartActions } from '../../../actions' -import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts' import { connect } from 'react-redux' import { Menu, MenuItem, Typography } from '@mui/material' +import { AppState } from '../../../reducers' +import { chartActions } from '../../../actions' +import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts' function chartParametersForAction(chart: ChartParameters, action: string) { return { @@ -37,18 +37,20 @@ function InterpolationSettings(props: { return callbacks }, [curves]) - const menuItems = React.useMemo(() => { - return curves.map(curve => ( - - {curve.replace(/_/g, ' ')} - - )) - }, [curves, props.chart]) + const menuItems = React.useMemo( + () => + curves.map(curve => ( + + {curve.replace(/_/g, ' ')} + + )), + [curves, props.chart] + ) return ( @@ -57,12 +59,10 @@ function InterpolationSettings(props: { ) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(InterpolationSettings) diff --git a/app/src/components/ChartPanel/ChartSettings/MoveUp.tsx b/app/src/components/ChartPanel/ChartSettings/MoveUp.tsx index a73ef66..42ef055 100644 --- a/app/src/components/ChartPanel/ChartSettings/MoveUp.tsx +++ b/app/src/components/ChartPanel/ChartSettings/MoveUp.tsx @@ -1,10 +1,10 @@ import * as React from 'react' import ArrowUpward from '@mui/icons-material/ArrowUpward' import { bindActionCreators } from 'redux' -import { chartActions } from '../../../actions' -import { ChartParameters } from '../../../reducers/Charts' import { connect } from 'react-redux' import { MenuItem, Typography, ListItemIcon } from '@mui/material' +import { chartActions } from '../../../actions' +import { ChartParameters } from '../../../reducers/Charts' function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartParameters; close: () => void }) { const moveUp = React.useCallback(() => { @@ -25,12 +25,10 @@ function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartPa ) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(MoveUp) diff --git a/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx b/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx index d1b2a45..0fc9bdc 100644 --- a/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx +++ b/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react' -import { ChartParameters } from '../../../reducers/Charts' import { Menu, TextField, Typography } from '@mui/material' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' +import { ChartParameters } from '../../../reducers/Charts' import { chartActions } from '../../../actions' import { KeyCodes } from '../../../utils/KeyCodes' @@ -50,7 +50,7 @@ function RangeSettings(props: Props) { () => ( { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(RangeSettings) diff --git a/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx b/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx index 0e761c6..529844c 100644 --- a/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx +++ b/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx @@ -1,7 +1,7 @@ import * as React from 'react' +import MoreVertIcon from '@mui/icons-material/Settings' import ChartSettings from '.' import CustomIconButton from '../../helper/CustomIconButton' -import MoreVertIcon from '@mui/icons-material/Settings' import { ChartParameters } from '../../../reducers/Charts' export function SettingsButton(props: { diff --git a/app/src/components/ChartPanel/ChartSettings/Size.tsx b/app/src/components/ChartPanel/ChartSettings/Size.tsx index 7494b52..facb0d2 100644 --- a/app/src/components/ChartPanel/ChartSettings/Size.tsx +++ b/app/src/components/ChartPanel/ChartSettings/Size.tsx @@ -1,8 +1,8 @@ import React, { memo } from 'react' -import { ChartParameters } from '../../../reducers/Charts' import { Menu, MenuItem, TextField, Typography } from '@mui/material' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' +import { ChartParameters } from '../../../reducers/Charts' import { chartActions } from '../../../actions' function Size(props: { @@ -39,12 +39,10 @@ function Size(props: { ) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(memo(Size)) diff --git a/app/src/components/ChartPanel/ChartSettings/TimeRangeSettings.tsx b/app/src/components/ChartPanel/ChartSettings/TimeRangeSettings.tsx index cecd117..2a80a8b 100644 --- a/app/src/components/ChartPanel/ChartSettings/TimeRangeSettings.tsx +++ b/app/src/components/ChartPanel/ChartSettings/TimeRangeSettings.tsx @@ -1,9 +1,10 @@ import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { bindActionCreators } from 'redux' import { Button, Menu, TextField, Typography } from '@mui/material' +import { connect } from 'react-redux' import { chartActions } from '../../../actions' import { ChartParameters } from '../../../reducers/Charts' -import { connect } from 'react-redux' + const parseDuration = require('parse-duration') interface Props { @@ -51,25 +52,23 @@ function TimeRangeSettings(props: Props) { return ( Chart data within a time interval
- {ranges.map(r => { - return ( - - ) - })} + {ranges.map(r => ( + + ))}
Limited to 500 data points @@ -88,12 +87,10 @@ function TimeRangeSettings(props: Props) { }, [value, props.open]) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(TimeRangeSettings) diff --git a/app/src/components/ChartPanel/ChartSettings/colors.ts b/app/src/components/ChartPanel/ChartSettings/colors.ts index 9701ec3..085538f 100644 --- a/app/src/components/ChartPanel/ChartSettings/colors.ts +++ b/app/src/components/ChartPanel/ChartSettings/colors.ts @@ -22,7 +22,7 @@ export function colors() { function colorCompare(colorA: string, colorB: string) { const a = colorToInt(colorA) const b = colorToInt(colorB) - return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)) + return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) } const colors: Array = [ brown, diff --git a/app/src/components/ChartPanel/ChartSettings/index.tsx b/app/src/components/ChartPanel/ChartSettings/index.tsx index e6ffc27..7f4537f 100644 --- a/app/src/components/ChartPanel/ChartSettings/index.tsx +++ b/app/src/components/ChartPanel/ChartSettings/index.tsx @@ -1,17 +1,17 @@ import BarChart from '@mui/icons-material/BarChart' import Clear from '@mui/icons-material/Refresh' import ColorLens from '@mui/icons-material/ColorLens' +import MultilineChart from '@mui/icons-material/MultilineChart' +import React, { memo } from 'react' +import Sort from '@mui/icons-material/Sort' +import { Menu, MenuItem, ListItemIcon, Typography } from '@mui/material' import ColorSettings from './ColorSettings' import InterpolationSettings from './InterpolationSettings' import MoveUp from './MoveUp' -import MultilineChart from '@mui/icons-material/MultilineChart' import RangeSettings from './RangeSettings' -import React, { memo } from 'react' import Size from './Size' -import Sort from '@mui/icons-material/Sort' import TimeRangeSettings from './TimeRangeSettings' import { ChartParameters } from '../../../reducers/Charts' -import { Menu, MenuItem, ListItemIcon, Typography } from '@mui/material' function ChartSettings(props: { open: boolean @@ -25,7 +25,7 @@ function ChartSettings(props: { const [interpolationVisible, setInterpolationVisible] = React.useState(false) const [sizeVisible, setSizeVisible] = React.useState(false) const [colorVisible, setColorVisible] = React.useState(false) - const open = props.open + const { open } = props const toggleRange = React.useCallback(() => { if (open) { diff --git a/app/src/components/ChartPanel/ChartTitle.tsx b/app/src/components/ChartPanel/ChartTitle.tsx index c9b0ca1..e0644a9 100644 --- a/app/src/components/ChartPanel/ChartTitle.tsx +++ b/app/src/components/ChartPanel/ChartTitle.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import { ChartParameters } from '../../reducers/Charts' import { Typography } from '@mui/material' import { withStyles } from '@mui/styles' import { Theme } from '@mui/material/styles' +import { ChartParameters } from '../../reducers/Charts' function ChartTitle(props: { parameters: ChartParameters; classes: any }) { const { classes, parameters } = props @@ -13,7 +13,7 @@ function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
- {parameters.dotPath ? parameters.topic : } + {parameters.dotPath ? parameters.topic : } ) @@ -21,10 +21,10 @@ function ChartTitle(props: { parameters: ChartParameters; classes: any }) { const styles = (theme: Theme) => ({ topic: { - wordBreak: 'break-all' as 'break-all', - whiteSpace: 'nowrap' as 'nowrap', - overflow: 'hidden' as 'hidden', - textOverflow: 'ellipsis' as 'ellipsis', + wordBreak: 'break-all' as const, + whiteSpace: 'nowrap' as const, + overflow: 'hidden' as const, + textOverflow: 'ellipsis' as const, }, }) diff --git a/app/src/components/ChartPanel/ChartWithTreeNode.tsx b/app/src/components/ChartPanel/ChartWithTreeNode.tsx index c93f29d..e1f8d81 100644 --- a/app/src/components/ChartPanel/ChartWithTreeNode.tsx +++ b/app/src/components/ChartPanel/ChartWithTreeNode.tsx @@ -1,5 +1,5 @@ -import * as q from '../../../../backend/src/Model' import React from 'react' +import * as q from '../../../../backend/src/Model' import TopicChart from './TopicChart' import { ChartParameters } from '../../reducers/Charts' import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode' diff --git a/app/src/components/ChartPanel/TopicChart.tsx b/app/src/components/ChartPanel/TopicChart.tsx index fd0cc11..aed411d 100644 --- a/app/src/components/ChartPanel/TopicChart.tsx +++ b/app/src/components/ChartPanel/TopicChart.tsx @@ -1,13 +1,14 @@ +import React, { useState, useCallback, memo, useRef } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { Paper } from '@mui/material' import * as q from '../../../../backend/src/Model' import ChartTitle from './ChartTitle' -import React, { useState, useCallback, memo, useRef } from 'react' import TopicPlot from '../TopicPlot' -import { bindActionCreators } from 'redux' import { ChartActions } from './ChartActions' import { chartActions } from '../../actions' import { ChartParameters } from '../../reducers/Charts' -import { connect } from 'react-redux' -import { Paper } from '@mui/material' + const throttle = require('lodash.throttle') class ClearableMessageBuffer extends q.RingBuffer { @@ -126,12 +127,10 @@ function TopicChart(props: Props) { ) } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) export default connect(undefined, mapDispatchToProps)(memo(TopicChart)) diff --git a/app/src/components/ChartPanel/index.tsx b/app/src/components/ChartPanel/index.tsx index 965a79c..80030c5 100644 --- a/app/src/components/ChartPanel/index.tsx +++ b/app/src/components/ChartPanel/index.tsx @@ -1,16 +1,17 @@ -import * as q from '../../../../backend/src/Model' import * as React from 'react' import ShowChart from '@mui/icons-material/ShowChart' -import { AppState } from '../../reducers' import { bindActionCreators } from 'redux' -import { chartActions } from '../../actions' -import { ChartParameters } from '../../reducers/Charts' -import { ChartWithTreeNode } from './ChartWithTreeNode' import { connect } from 'react-redux' import { Grid, Typography } from '@mui/material' import { withStyles } from '@mui/styles' import { Theme } from '@mui/material/styles' import { List } from 'immutable' +import * as q from '../../../../backend/src/Model' +import { AppState } from '../../reducers' +import { ChartWithTreeNode } from './ChartWithTreeNode' +import { ChartParameters } from '../../reducers/Charts' +import { chartActions } from '../../actions' + const { TransitionGroup, CSSTransition } = require('react-transition-group/esm') interface Props { @@ -26,11 +27,11 @@ interface Props { function spacingForChartCount(count: number): 4 | 6 | 12 { if (count >= 5) { return 4 - } else if (count >= 2) { - return 6 - } else { - return 12 } + if (count >= 2) { + return 6 + } + return 12 } function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpacing: 4 | 6 | 12): 4 | 6 | 12 { @@ -46,7 +47,6 @@ function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpaci } } - // Helper function to generate unique keys for charts const getChartKey = (chart: ChartParameters) => `${chart.topic}-${chart.dotPath || ''}` @@ -74,32 +74,27 @@ function ChartPanel(props: Props) { React.useEffect(() => { const currentKeys = new Set(props.charts.map(getChartKey).toArray()) const refsToDelete: string[] = [] - + nodeRefsMap.current.forEach((_, key) => { if (!currentKeys.has(key)) { refsToDelete.push(key) } }) - + refsToDelete.forEach(key => nodeRefsMap.current.delete(key)) }, [props.charts]) const charts = props.charts.map(chartParameters => { const key = getChartKey(chartParameters) - + // Get or create a ref for this specific chart if (!nodeRefsMap.current.has(key)) { nodeRefsMap.current.set(key, React.createRef()) } const nodeRef = nodeRefsMap.current.get(key)! - + return ( - + @@ -131,21 +126,17 @@ function NoCharts() { ) } -const mapStateToProps = (state: AppState) => { - return { - charts: state.charts.get('charts'), - connectionId: state.connection.connectionId, - tree: state.connection.tree, - } -} +const mapStateToProps = (state: AppState) => ({ + charts: state.charts.get('charts'), + connectionId: state.connection.connectionId, + tree: state.connection.tree, +}) -const mapDispatchToProps = (dispatch: any) => { - return { - actions: { - chart: bindActionCreators(chartActions, dispatch), - }, - } -} +const mapDispatchToProps = (dispatch: any) => ({ + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, +}) const styles = (theme: Theme) => ({ container: { diff --git a/app/src/components/ConfirmationDialog.tsx b/app/src/components/ConfirmationDialog.tsx index bc47db9..ca78986 100644 --- a/app/src/components/ConfirmationDialog.tsx +++ b/app/src/components/ConfirmationDialog.tsx @@ -1,6 +1,6 @@ import React, { useRef, useCallback, memo } from 'react' -import { ConfirmationRequest } from '../reducers/Global' import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material' +import { ConfirmationRequest } from '../reducers/Global' import { KeyCodes } from '../utils/KeyCodes' function ConfirmationDialog(props: { confirmationRequests: Array }) { @@ -34,7 +34,7 @@ function ConfirmationDialog(props: { confirmationRequests: Array { const [qos, setQos] = useState(0) const [topic, setTopic] = useState('') const { classes } = props @@ -42,9 +43,9 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) { return (
-
- - + + + - +
- + - + - + - +
- +