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 <thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-30 20:53:29 +01:00
committed by GitHub
parent 080a773dbd
commit ed8a7f559e
194 changed files with 35234 additions and 4085 deletions

View File

@@ -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"
]
}

38
.github/workflows/lint.yml vendored Normal file
View File

@@ -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

View File

@@ -20,8 +20,6 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Packages
run: yarn install --frozen-lockfile
- name: Lint
run: yarn lint:eslint
- name: Build
run: yarn build
- name: Test

334
DEBUG_EXAMPLES.md Normal file
View File

@@ -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.

View File

@@ -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):**

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

201
TESTING_WITH_API.md Normal file
View File

@@ -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`

View File

@@ -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"

View File

@@ -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<ConnectionViewStateDictio
}
export const loadCharts = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionId = getState().connection.connectionId
const { connectionId } = getState().connection
if (!connectionId) {
return
}
@@ -40,7 +40,7 @@ export const loadCharts = () => async (dispatch: Dispatch<any>, getState: () =>
}
export const saveCharts = () => async (dispatch: Dispatch<any>, 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<ChartParameters>): Action => {
return {
charts,
type: ActionTypes.CHARTS_SET,
}
}
export const setCharts = (charts: Array<ChartParameters>): Action => ({
charts,
type: ActionTypes.CHARTS_SET,
})

View File

@@ -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'

View File

@@ -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

View File

@@ -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<any>) => {
})
}
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
return new Promise(resolve => {
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) =>
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<any>) => {
return new Promise((resolve, reject) => {
export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch<any>) =>
new Promise((resolve, reject) => {
dispatch({
confirmationRequest,
type: ActionTypes.removeConfirmationRequest,
})
})
}

View File

@@ -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<FileParameters
}
}
export const setPayload = (payload?: string): Action => {
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<Action>, getState: () => AppState) => {
const state = getState()
@@ -97,8 +89,6 @@ export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, ge
rendererEvents.emit(publishEvent, mqttMessage)
}
export const toggleRetain = (): Action => {
return {
type: ActionTypes.PUBLISH_TOGGLE_RETAIN,
}
}
export const toggleRetain = (): Action => ({
type: ActionTypes.PUBLISH_TOGGLE_RETAIN,
})

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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,
})

View File

@@ -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

View File

@@ -21,7 +21,7 @@ export interface ConnectionOptionsV0 {
subscriptions: Array<string>
}
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
})

View File

@@ -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<TopicViewModel> | 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<TopicViewModel>): q.TreeNode<TopicViewModel> {
@@ -132,7 +130,6 @@ function findNextNodeDownwardNeighbor(
const downwardNeighbor = neighborNodes[nodeIdx + 1]
if (downwardNeighbor) {
return downwardNeighbor
} else {
return findNextNodeDownwardNeighbor(settings, parent)
}
return findNextNodeDownwardNeighbor(settings, parent)
}

View File

@@ -1,13 +1,13 @@
// 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') {

View File

@@ -23,13 +23,15 @@ 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')
@@ -38,14 +40,16 @@ socket.on('connect_error', (error) => {
// 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)
})
@@ -54,9 +58,11 @@ socket.on('connect', () => {
// 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' },
})
)
}
})
@@ -66,9 +72,11 @@ socket.on('auth-status', (data: { authDisabled: boolean }) => {
// 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 },
})
)
}
})
@@ -78,9 +86,11 @@ socket.on('auto-connect-config', (config: any) => {
// 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,
})
)
}
})
@@ -90,9 +100,28 @@ socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
// 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 },
})
)
}
})

View File

@@ -52,8 +52,8 @@ describe('AboutDialog License Compliance', () => {
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.'
)
}
@@ -67,8 +67,8 @@ describe('AboutDialog License Compliance', () => {
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.'
)
}
@@ -82,8 +82,8 @@ describe('AboutDialog License Compliance', () => {
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.'
)
}

View File

@@ -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'
@@ -71,12 +71,16 @@ export function AboutDialog(props: AboutDialogProps) {
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }} data-testid="about-author">
<Avatar
src="https://github.com/thomasnordquist.png"
alt="Thomas Nordquist"
sx={{ width: 56, height: 56 }}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mb: 2,
}}
data-testid="about-author"
>
<Avatar src="https://github.com/thomasnordquist.png" alt="Thomas Nordquist" sx={{ width: 56, height: 56 }} />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
Thomas Nordquist

View File

@@ -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<Props, {}> {
onClose={() => this.props.actions.toggleAboutDialogVisibility()}
/>
{this.renderNotification()}
<React.Suspense fallback={<div></div>}>
<React.Suspense fallback={<div />}>
<Settings {...anyProps} />
</React.Suspense>
<div className={centerContent}>
@@ -90,7 +90,7 @@ class App extends React.PureComponent<Props, {}> {
<TitleBar />
</div>
<div className={settingsVisible ? contentShift : content}>
<React.Suspense fallback={<div></div>}>
<React.Suspense fallback={<div />}>
<ContentView
heightProperty={heightProperty}
connectionId={this.props.connectionId}
@@ -121,12 +121,12 @@ const styles = (theme: Theme) => {
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))

View File

@@ -11,7 +11,7 @@ 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(<Chart data={data} />)
@@ -33,7 +33,9 @@ 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`))
@@ -51,7 +53,7 @@ describe('Chart X-Axis Domain Investigation', () => {
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`)
@@ -63,15 +65,16 @@ describe('Chart X-Axis Domain Investigation', () => {
// 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,7 +85,7 @@ 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(<Chart data={data} />)
@@ -97,8 +100,14 @@ 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)
@@ -106,7 +115,6 @@ describe('Chart X-Axis Domain Investigation', () => {
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')
})
})

View File

@@ -92,7 +92,7 @@ describe('Chart Component', () => {
const { container } = renderWithProviders(<Chart data={data} />, { 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')
@@ -139,13 +139,12 @@ 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(
<Chart data={data} interpolation={interpolation} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} interpolation={interpolation} />, {
withTheme: true,
})
expect(container.querySelector('svg')).to.exist
const paths = container.querySelectorAll('path')
@@ -158,10 +157,7 @@ describe('Chart Component', () => {
it('should apply custom color', () => {
const data = createMockChartData(5)
const customColor = '#ff0000'
const { container } = renderWithProviders(
<Chart data={data} color={customColor} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} color={customColor} />, { withTheme: true })
// Check if custom color is applied to line or glyphs
const svg = container.querySelector('svg')
@@ -180,10 +176,7 @@ describe('Chart Component', () => {
it('should render with custom Y range', () => {
const data = createMockChartData(5)
const range: [number, number] = [0, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -191,10 +184,9 @@ describe('Chart Component', () => {
it('should render with custom time range', () => {
const data = createMockChartData(5)
const timeRangeStart = 60000 // 1 minute
const { container } = renderWithProviders(
<Chart data={data} timeRangeStart={timeRangeStart} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} timeRangeStart={timeRangeStart} />, {
withTheme: true,
})
expect(container.querySelector('svg')).to.exist
})
@@ -202,10 +194,7 @@ describe('Chart Component', () => {
it('should render with partial Y range (only min)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [0, undefined]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -213,10 +202,7 @@ describe('Chart Component', () => {
it('should render with partial Y range (only max)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [undefined, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -250,7 +236,7 @@ describe('Chart Component', () => {
// 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
}
@@ -392,10 +378,7 @@ describe('Chart Component', () => {
})
it('should handle rapid data updates', () => {
const { rerender, container } = renderWithProviders(
<Chart data={createMockChartData(5)} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={createMockChartData(5)} />, { withTheme: true })
// Simulate rapid updates
for (let i = 0; i < 10; i++) {
@@ -410,10 +393,7 @@ 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(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify initial state: should have 3 data points
const initialCircles = container.querySelectorAll('circle')
@@ -471,10 +451,7 @@ describe('Chart Component', () => {
it('should handle data point removal', () => {
// Start with 5 data points
const initialData = createMockChartData(5)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify initial state
let circles = container.querySelectorAll('circle')
@@ -491,10 +468,7 @@ describe('Chart Component', () => {
it('should maintain chart structure during data updates', () => {
const initialData = createMockChartData(3)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify chart structure exists initially
expect(container.querySelector('svg')).to.exist

View File

@@ -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: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
{ title: <b>Time</b>, value: <DateFormatter timeFirst date={new Date(point.x)} /> },
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
],
@@ -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(
@@ -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,12 +101,17 @@ export default memo((props: Props) => {
<XYChart
width={width || 300}
height={CHART_HEIGHT}
margin={{ top: 10, right: 10, bottom: 30, left: 50 }}
margin={{
top: 10,
right: 10,
bottom: 30,
left: 50,
}}
xScale={{ type: 'time', domain: xDomain || dummyDomain }}
yScale={{ type: 'linear', domain: hasData ? yDomain : dummyDomain }}
onPointerOut={onMouseLeave}
>
<Grid rows={true} columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
<Grid rows columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
<Axis
orientation="left"
numTicks={5}
@@ -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 (
<circle
cx={glyphProps.x}
cy={glyphProps.y}
r={3}
fill={pointColor}
/>
)
return <circle cx={glyphProps.x} cy={glyphProps.y} r={3} fill={pointColor} />
}}
/>
</XYChart>

View File

@@ -8,9 +8,9 @@ function TooltipComponent(props: { tooltip?: Tooltip }) {
const { tooltip } = props
return (
<Popper
style={Boolean(tooltip) ? { transition: 'all 0.1s ease-out' } : undefined}
style={tooltip ? { transition: 'all 0.1s ease-out' } : undefined}
open={Boolean(tooltip)}
transition={true}
transition
placement="top"
anchorEl={tooltip && tooltip.element}
>
@@ -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
),
}}

View File

@@ -13,12 +13,11 @@ export function useCustomXDomain(props: Props): [number, number] | undefined {
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])
}

View File

@@ -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])]

View File

@@ -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) {

View File

@@ -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 => (
<MenuItem
style={{ minWidth: '8em', minHeight: '36px', backgroundColor: color, textAlign: 'center' }}
key={color}
onClick={() => setColor(color)}
>
{props.chart.color === color ? 'X' : ''}
</MenuItem>
))
}, [colors, props.chart])
const menuItems = React.useMemo(
() =>
colors.map(color => (
<MenuItem
style={{
minWidth: '8em',
minHeight: '36px',
backgroundColor: color,
textAlign: 'center',
}}
key={color}
onClick={() => setColor(color)}
>
{props.chart.color === color ? 'X' : ''}
</MenuItem>
)),
[colors, props.chart]
)
return (
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
@@ -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))

View File

@@ -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 => (
<MenuItem
key={curve}
onClick={callbacks[curve]}
selected={props.chart.interpolation === curve}
data-menu-item={curve.replace(/_/g, ' ')}
>
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
</MenuItem>
))
}, [curves, props.chart])
const menuItems = React.useMemo(
() =>
curves.map(curve => (
<MenuItem
key={curve}
onClick={callbacks[curve]}
selected={props.chart.interpolation === curve}
data-menu-item={curve.replace(/_/g, ' ')}
>
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
</MenuItem>
)),
[curves, props.chart]
)
return (
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
@@ -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)

View File

@@ -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)

View File

@@ -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) {
() => (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
keepMounted
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
@@ -62,7 +62,7 @@ function RangeSettings(props: Props) {
inputProps={{
ref: rangeFromRef,
}}
autoFocus={true}
autoFocus
style={{ marginTop: '0' }}
label="from"
value={rangeFrom}
@@ -87,13 +87,11 @@ function RangeSettings(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)(RangeSettings)

View File

@@ -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: {

View File

@@ -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))

View File

@@ -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 (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
keepMounted
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
>
<Typography>Chart data within a time interval</Typography>
<div style={{ padding: '0 16px', width: '275px', textAlign: 'center' }}>
{ranges.map(r => {
return (
<Button
style={{ margin: '4px', textTransform: 'none' }}
variant="contained"
key={r}
onClick={createRangeHandler(r)}
>
{r}
</Button>
)
})}
{ranges.map(r => (
<Button
style={{ margin: '4px', textTransform: 'none' }}
variant="contained"
key={r}
onClick={createRangeHandler(r)}
>
{r}
</Button>
))}
</div>
<Typography style={{ fontSize: '0.75em' }}>
<i>Limited to 500 data points</i>
@@ -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)

View File

@@ -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<string> = [
brown,

View File

@@ -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) {

View File

@@ -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 }) {
</Typography>
<br />
<Typography variant="caption" className={classes.topic}>
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: '&nbsp;' }}></span>}
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: '&nbsp;' }} />}
</Typography>
</div>
)
@@ -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,
},
})

View File

@@ -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'

View File

@@ -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<q.Message> {
@@ -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))

View File

@@ -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 || ''}`
@@ -94,12 +94,7 @@ function ChartPanel(props: Props) {
const nodeRef = nodeRefsMap.current.get(key)!
return (
<CSSTransition
key={key}
timeout={{ enter: 500, exit: 500 }}
classNames="example"
nodeRef={nodeRef}
>
<CSSTransition key={key} timeout={{ enter: 500, exit: 500 }} classNames="example" nodeRef={nodeRef}>
<Grid item xs={mapWidth(chartParameters.width, spacing)} ref={nodeRef}>
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
</Grid>
@@ -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: {

View File

@@ -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<ConfirmationRequest> }) {
@@ -34,7 +34,7 @@ function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationReq
return (
<Dialog
open={true}
open
onClose={reject}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"

View File

@@ -5,14 +5,15 @@ import Lock from '@mui/icons-material/Lock'
import Undo from '@mui/icons-material/Undo'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Grid, TextField, Tooltip } from '@mui/material'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
import { connectionManagerActions } from '../../actions'
import { QosSelect } from '../QosSelect'
import { QoS } from '../../../../backend/src/DataSource/MqttSource'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import Subscriptions from './Subscriptions'
const SubscriptionsAny = Subscriptions as any
interface Props {
@@ -21,7 +22,7 @@ interface Props {
managerActions: typeof connectionManagerActions
}
const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
const ConnectionSettings = memo((props: Props) => {
const [qos, setQos] = useState<QoS>(0)
const [topic, setTopic] = useState('')
const { classes } = props
@@ -42,9 +43,9 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
return (
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={8} className={classes.gridPadding}>
<form className={classes.container} noValidate autoComplete="off">
<Grid container spacing={3}>
<Grid item xs={8} className={classes.gridPadding}>
<TextField
className={`${classes.fullWidth} advanced-connection-settings-topic-input`}
label="Topic"
@@ -54,12 +55,12 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
onChange={updateSubscription}
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<div className={classes.qos}>
<QosSelect label="QoS" selected={qos} onChange={setQos} />
</div>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<Button
className={classes.button}
color="secondary"
@@ -70,10 +71,10 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
<Add /> Add
</Button>
</Grid>
<Grid item={true} xs={12} style={{ padding: 0 }}>
<Grid item xs={12} style={{ padding: 0 }}>
<SubscriptionsAny connection={props.connection} />
</Grid>
<Grid item={true} xs={7} className={classes.gridPadding}>
<Grid item xs={7} className={classes.gridPadding}>
<TextField
className={classes.fullWidth}
label="MQTT Client ID"
@@ -82,7 +83,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
onChange={handleChange('clientId')}
/>
</Grid>
<Grid item={true} xs={3} className={classes.gridPadding}>
<Grid item xs={3} className={classes.gridPadding}>
<div>
<Tooltip title="Manage tls connection certificates" placement="top">
<Button
@@ -95,7 +96,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
</Tooltip>
</div>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<Button
variant="contained"
className={classes.button}
@@ -111,11 +112,9 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
)
})
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
fullWidth: {
@@ -126,7 +125,7 @@ const styles = (theme: Theme) => ({
},
button: {
marginTop: theme.spacing(3),
float: 'right' as 'right',
float: 'right' as const,
},
qos: {
marginTop: theme.spacing(1),

View File

@@ -1,15 +1,15 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Lock from '@mui/icons-material/Lock'
import { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { withStyles } from '@mui/styles'
import { RpcEvents } from '../../../../events/EventsV2'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@mui/styles'
import ClearAdornment from '../helper/ClearAdornment'
import { rendererRpc } from '../../eventBus'
import { RpcEvents } from '../../../../events/EventsV2'
function BrowserCertificateFileSelection(props: {
certificateType: CertificateTypes
@@ -114,21 +114,19 @@ function ClearCertificate(props: { classes: any; certificate?: CertificateParame
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
overflow: 'hidden' as const,
whiteSpace: 'nowrap' as const,
textOverflow: 'ellipsis' as const,
color: theme.palette.text.secondary,
},
button: {

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Lock from '@mui/icons-material/Lock'
import { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { withStyles } from '@mui/styles'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@mui/styles'
import ClearAdornment from '../helper/ClearAdornment'
function CertificateFileSelection(props: {
certificateType: CertificateTypes
@@ -56,21 +56,19 @@ function ClearCertificate(props: { classes: any; certificate?: CertificateParame
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
overflow: 'hidden' as const,
whiteSpace: 'nowrap' as const,
textOverflow: 'ellipsis' as const,
color: theme.palette.text.secondary,
},
button: {

View File

@@ -1,14 +1,14 @@
import * as React from 'react'
import CertificateFileSelection from './CertificateFileSelection'
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
import Undo from '@mui/icons-material/Undo'
import { bindActionCreators } from 'redux'
import { Button, Grid } from '@mui/material'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
import CertificateFileSelection from './CertificateFileSelection'
import { isBrowserMode } from '../../utils/browserMode'
// Use browser or desktop file selection based on mode
@@ -48,9 +48,9 @@ class Certificates extends React.PureComponent<Props, State> {
const { classes } = this.props
return (
<div>
<form noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={12} className={classes.gridPadding}>
<form noValidate autoComplete="off">
<Grid container spacing={3}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.selfSignedCertificate}
@@ -58,7 +58,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="selfSignedCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientCertificate}
@@ -66,7 +66,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="clientCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientKey}
@@ -74,7 +74,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="clientKey"
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<br />
<Button
variant="contained"
@@ -91,11 +91,9 @@ class Certificates extends React.PureComponent<Props, State> {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
fullWidth: {

View File

@@ -1,7 +1,7 @@
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import PowerSettingsNew from '@mui/icons-material/PowerSettingsNew'
import React from 'react'
import { Button } from '@mui/material'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) {
const { classes, toggle, connecting } = props

View File

@@ -1,29 +1,21 @@
import ConnectButton from './ConnectButton'
import React, { useCallback, useState } from 'react'
import Save from '@mui/icons-material/Save'
import Delete from '@mui/icons-material/Delete'
import Settings from '@mui/icons-material/Settings'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Grid, IconButton, InputAdornment, MenuItem, TextField, Tooltip } from '@mui/material'
import { AppState } from '../../reducers'
import { connectionActions, connectionManagerActions, globalActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { KeyCodes } from '../../utils/KeyCodes'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { ToggleSwitch } from './ToggleSwitch'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import {
Button,
Grid,
IconButton,
InputAdornment,
MenuItem,
TextField,
Tooltip,
} from '@mui/material'
import ConnectButton from './ConnectButton'
interface Props {
connection: ConnectionOptions
@@ -80,7 +72,7 @@ function ConnectionSettings(props: Props) {
function renderBasePathInput() {
return (
<Grid item={true} xs={4}>
<Grid item xs={4}>
<TextField
label="Basepath"
className={props.classes.textField}
@@ -111,21 +103,23 @@ function ConnectionSettings(props: Props) {
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
{value}
://
{value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
</MenuItem>
))
return (
<Tooltip title="Use 'mqtt' for standard connections or 'ws' for WebSocket connections" arrow>
<TextField
select={true}
select
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={updateProtocol}
margin="dense"
inputProps={{
'aria-label': 'MQTT protocol'
'aria-label': 'MQTT protocol',
}}
>
{protocolItems}
@@ -135,7 +129,7 @@ function ConnectionSettings(props: Props) {
}
const updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
const { value } = event.target
updateConnection('protocol', value)
if (event.target.value === 'mqtt') {
updateConnection('basePath', undefined)
@@ -159,9 +153,9 @@ function ConnectionSettings(props: Props) {
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
return (
<InputAdornment position="end">
<Tooltip title={props.showPassword ? "Hide password" : "Show password"} arrow>
<Tooltip title={props.showPassword ? 'Hide password' : 'Show password'} arrow>
<IconButton
aria-label={props.showPassword ? "Hide password" : "Show password"}
aria-label={props.showPassword ? 'Hide password' : 'Show password'}
onClick={props.toggle}
edge="end"
>
@@ -176,11 +170,11 @@ function ConnectionSettings(props: Props) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<form className={classes.container} noValidate={true} autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
<Grid container={true} spacing={2}>
<Grid item={true} xs={5}>
<form className={classes.container} noValidate autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
<Grid container spacing={2}>
<Grid item xs={5}>
<TextField
autoFocus={true}
autoFocus
label="Name"
className={classes.textField}
value={connection.name}
@@ -188,11 +182,11 @@ function ConnectionSettings(props: Props) {
margin="dense"
placeholder="My MQTT Connection"
inputProps={{
'aria-label': 'Connection name'
'aria-label': 'Connection name',
}}
/>
</Grid>
<Grid item={true} xs={4}>
<Grid item xs={4}>
<ToggleSwitch
label="Validate certificate"
classes={classes}
@@ -200,13 +194,13 @@ function ConnectionSettings(props: Props) {
toggle={toggleCertValidation}
/>
</Grid>
<Grid item={true} xs={3}>
<Grid item xs={3}>
<ToggleSwitch label="Encryption (tls)" classes={classes} value={connection.encryption} toggle={toggleTls} />
</Grid>
<Grid item={true} xs={2}>
<Grid item xs={2}>
{renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<Grid item xs={7}>
<TextField
label="Host"
className={classes.textField}
@@ -216,11 +210,11 @@ function ConnectionSettings(props: Props) {
placeholder="broker.example.com"
inputProps={{
'data-testid': 'host-input',
'aria-label': 'MQTT broker host'
'aria-label': 'MQTT broker host',
}}
/>
</Grid>
<Grid item={true} xs={3}>
<Grid item xs={3}>
<TextField
label="Port"
className={classes.textField}
@@ -232,12 +226,12 @@ function ConnectionSettings(props: Props) {
inputProps={{
'aria-label': 'MQTT broker port',
min: 1,
max: 65535
max: 65535,
}}
/>
</Grid>
{requiresBasePath() ? renderBasePathInput() : null}
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<Grid item xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Username"
className={classes.textField}
@@ -247,11 +241,11 @@ function ConnectionSettings(props: Props) {
placeholder="Optional"
inputProps={{
'aria-label': 'MQTT username',
'autoComplete': 'username'
autoComplete: 'username',
}}
/>
</Grid>
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<Grid item xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Password"
className={classes.textField}
@@ -261,17 +255,25 @@ function ConnectionSettings(props: Props) {
margin="dense"
placeholder="Optional"
InputProps={{
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />,
}}
inputProps={{
'aria-label': 'MQTT password',
'autoComplete': 'current-password'
autoComplete: 'current-password',
}}
/>
</Grid>
</Grid>
</form>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(0, 0, 0, 0.12)' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '16px',
borderTop: '1px solid rgba(0, 0, 0, 0.12)',
}}
>
<div>
<Tooltip title="Delete this connection permanently" arrow>
<Button
@@ -315,20 +317,16 @@ function ConnectionSettings(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
connected: state.connection.connected,
connecting: state.connection.connecting,
}
}
const mapStateToProps = (state: AppState) => ({
connected: state.connection.connected,
connecting: state.connection.connecting,
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
const styles = (theme: Theme) => ({
textField: {

View File

@@ -1,19 +1,20 @@
import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
const ConnectionSettingsAny = ConnectionSettings as any
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Modal, Paper, Toolbar, Typography, Collapse } from '@mui/material'
import ConnectionSettings from './ConnectionSettings'
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
const AdvancedConnectionSettingsAny = AdvancedConnectionSettings as any
import Certificates from './Certificates'
const ConnectionSettingsAny = ConnectionSettings as any
const AdvancedConnectionSettingsAny = AdvancedConnectionSettings as any
const CertificatesAny = Certificates as any
interface Props {
@@ -60,7 +61,7 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
const mqttConnection = connection && toMqttConnection(connection)
return (
<div>
<Modal open={visible} disableAutoFocus={true}>
<Modal open={visible} disableAutoFocus>
<Paper className={classes.root}>
<div className={classes.left}>
<ProfileList />
@@ -90,7 +91,7 @@ const connectionHeight = '440px'
const styles = (theme: Theme) => ({
title: {
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
whiteSpace: 'nowrap' as const,
},
toolbarContent: {
width: '100%',
@@ -103,7 +104,7 @@ const styles = (theme: Theme) => ({
flex: 1,
// Hide on mobile - connection selector will take its place
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
root: {
@@ -111,29 +112,29 @@ const styles = (theme: Theme) => ({
minWidth: '800px',
maxWidth: '850px',
height: connectionHeight,
outline: 'none' as 'none',
display: 'flex' as 'flex',
outline: 'none' as const,
display: 'flex' as const,
// Mobile responsive adjustments
[theme.breakpoints.down('md')]: {
minWidth: '95vw',
maxWidth: '95vw',
height: '85vh',
margin: '7.5vh auto 0 auto',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
},
},
left: {
borderRightStyle: 'dotted' as 'dotted',
borderRightStyle: 'dotted' as const,
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
paddingTop: theme.spacing(2),
flex: 3,
overflow: 'hidden' as 'hidden',
overflow: 'hidden' as const,
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
// Mobile: hide profile list to save space
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
right: {
@@ -144,35 +145,31 @@ const styles = (theme: Theme) => ({
// Mobile: enable scrolling
[theme.breakpoints.down('md')]: {
borderRadius: `${theme.shape.borderRadius}px`,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
},
},
connectionUri: {
width: '27em',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
color: theme.palette.text.secondary,
fontSize: '0.9em',
marginLeft: theme.spacing(4),
},
})
const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
showCertificateSettings: state.connectionManager.showCertificateSettings,
connection: state.connectionManager.selected
? state.connectionManager.connections[state.connectionManager.selected]
: undefined,
}
}
const mapStateToProps = (state: AppState) => ({
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
showCertificateSettings: state.connectionManager.showCertificateSettings,
connection: state.connectionManager.selected
? state.connectionManager.connections[state.connectionManager.selected]
: undefined,
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup) as any)

View File

@@ -1,12 +1,12 @@
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionManagerActions } from '../../actions'
import { AppState } from '../../reducers'
const styles = (theme: Theme) => ({
container: {
@@ -51,9 +51,8 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
this.props.actions.createConnection()
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
return connection.name || connection.host || 'Unnamed Connection'
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) =>
connection.name || connection.host || 'Unnamed Connection'
public render() {
const { classes, connections, currentConnectionId, isConnected, currentActiveConnectionId } = this.props
@@ -110,7 +109,7 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
}
const mapStateToProps = (state: AppState) => {
const connectionManager = state.connectionManager
const { connectionManager } = state
const connections =
connectionManager && connectionManager.connections
? Object.values(connectionManager.connections).map(conn => ({
@@ -128,11 +127,9 @@ const mapStateToProps = (state: AppState) => {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
// Using 'as any' here is consistent with other Material-UI + Redux connected components
// in this codebase (see ConnectionSettings.tsx, ProfileList/index.tsx, ChartPanel/index.tsx)

View File

@@ -15,12 +15,10 @@ const styles = (theme: Theme) => ({
},
})
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => {
return (
<span id="addProfileButton" style={{ marginRight: '12px' }}>
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
<Add className={props.classes.addIcon} />
</Fab>
</span>
)
})
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => (
<span id="addProfileButton" style={{ marginRight: '12px' }}>
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
<Add className={props.classes.addIcon} />
</Fab>
</span>
))

View File

@@ -1,10 +1,10 @@
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { ListItem, Typography } from '@mui/material'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { bindActionCreators } from 'redux'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { connectionActions, connectionManagerActions } from '../../../actions'
export interface Props {
@@ -17,7 +17,7 @@ export interface Props {
classes: any
}
const ConnectionItem = (props: Props) => {
function ConnectionItem(props: Props) {
const connect = useCallback(() => {
const mqttOptions = toMqttConnection(props.connection)
if (mqttOptions) {
@@ -28,7 +28,7 @@ const ConnectionItem = (props: Props) => {
const connection = props.connection.host && toMqttConnection(props.connection)
return (
<ListItem
button={true}
button
selected={props.selected}
style={{ display: 'block' }}
onClick={() => props.actions.connectionManager.selectConnection(props.connection.id)}
@@ -43,26 +43,24 @@ const ConnectionItem = (props: Props) => {
)
}
export const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connection: bindActionCreators(connectionActions, dispatch),
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
export const mapDispatchToProps = (dispatch: any) => ({
actions: {
connection: bindActionCreators(connectionActions, dispatch),
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
export const connectionItemStyle = (theme: Theme) => ({
name: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
},
details: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
color: theme.palette.text.secondary,
fontSize: '0.7em',
},

View File

@@ -1,18 +1,19 @@
import ConnectionItem from './ConnectionItem'
const ConnectionItemAny = ConnectionItem as any
import React from 'react'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../../actions'
import { ConnectionOptions } from '../../../model/ConnectionOptions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { List } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import ConnectionItem from './ConnectionItem'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { connectionManagerActions } from '../../../actions'
import { ConnectionOptions } from '../../../model/ConnectionOptions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
const ConnectionItemAny = ConnectionItem as any
interface Props {
classes: any
selected?: string
@@ -62,21 +63,17 @@ const styles = (theme: Theme) => ({
list: {
marginTop: theme.spacing(1),
height: `calc(100% - ${theme.spacing(6)})`,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
}
}
const mapStateToProps = (state: AppState) => ({
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList) as any)

View File

@@ -1,7 +1,5 @@
import React, { useCallback, useState } from 'react'
import Delete from '@mui/icons-material/Delete'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import {
IconButton,
TableContainer,
@@ -16,6 +14,8 @@ import {
import { bindActionCreators } from 'redux'
import { withStyles } from '@mui/styles'
import { connect } from 'react-redux'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { connectionManagerActions } from '../../actions'
function Subscriptions(props: {
classes: any
@@ -29,7 +29,7 @@ function Subscriptions(props: {
<Table size="small">
<TableHead>
<TableRow>
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell}></TableCell>
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell} />
<TableCell className={classes.tableTitleCell}>Topic</TableCell>
<TableCell align="right" className={classes.tableTitleCell}>
QoS
@@ -38,7 +38,7 @@ function Subscriptions(props: {
</TableHead>
<TableBody>
{connection.subscriptions.map(subscription => (
<TableRow key={subscription.topic + '_qos_' + subscription.qos}>
<TableRow key={`${subscription.topic}_qos_${subscription.qos}`}>
<TableCell align="right" className={classes.tableCell}>
<IconButton
onClick={() => managerActions.deleteSubscription(subscription, connection.id)}
@@ -62,11 +62,9 @@ function Subscriptions(props: {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
tableCell: {
@@ -80,7 +78,7 @@ const styles = (theme: Theme) => ({
},
topicList: {
height: '196px',
overflowY: 'scroll' as 'scroll',
overflowY: 'scroll' as const,
margin: `${theme.spacing(1)}px ${theme.spacing(1)}px 0 ${theme.spacing(1)}px`,
backgroundColor: theme.palette.background.default,
width: 'auto',

View File

@@ -11,17 +11,13 @@ export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () =
role="switch"
aria-checked={value}
inputProps={{
'aria-label': label
'aria-label': label,
}}
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={toggleSwitch}
label={`${label} (${value ? 'On' : 'Off'})`}
labelPlacement="bottom"
/>
<FormControlLabel control={toggleSwitch} label={`${label} (${value ? 'On' : 'Off'})`} labelPlacement="bottom" />
</div>
)
}

View File

@@ -24,20 +24,20 @@ class Key extends React.Component<Props, {}> {
const style = (theme: Theme) => ({
keyStyle: {
display: 'inline-block' as 'inline-block',
display: 'inline-block' as const,
width: '1em',
height: '1em',
backgroundColor: '#bbb',
borderRadius: '10%',
verticalAlign: 'middle' as 'middle',
textAlign: 'center' as 'center',
verticalAlign: 'middle' as const,
textAlign: 'center' as const,
textShadow: '1px 1px rgba(255,255,255,0.45)',
boxShadow: '0.08em 0.15em 0.01em 0px rgba(100,100,100,0.75)',
},
keyTextStyle: {
marginTop: '0.65em',
fontSize: '0.4em',
fontWeight: 'bold' as 'bold',
fontWeight: 'bold' as const,
},
})

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const cursor = require('./cursor.png')
interface State {
@@ -13,11 +14,18 @@ interface State {
class Demo extends React.Component<{ classes: any }, State> {
private timer: any
private frameInterval = 20
constructor(props: any) {
super(props)
this.state = { enabled: false, target: { x: 0, y: 0 }, position: { x: 0, y: 0 }, stepSizeX: 1, stepSizeY: 1 }
this.state = {
enabled: false,
target: { x: 0, y: 0 },
position: { x: 0, y: 0 },
stepSizeX: 1,
stepSizeY: 1,
}
}
private moveCloser(steps: number = 0) {
@@ -50,7 +58,12 @@ class Demo extends React.Component<{ classes: any }, State> {
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
this.setState({
stepSizeX,
stepSizeY,
enabled: true,
target: { x, y },
})
this.moveCloser()
}
}
@@ -73,10 +86,10 @@ const style = (theme: Theme) => ({
cursor: {
width: '32px',
height: '32px',
position: 'fixed' as 'fixed',
position: 'fixed' as const,
zIndex: 1000000,
filter: theme.palette.mode === 'light' ? undefined : 'invert(100%)',
pointerEvents: 'none' as 'none',
pointerEvents: 'none' as const,
},
})

View File

@@ -11,6 +11,7 @@ interface State {
class Demo extends React.Component<{ classes: any }, State> {
private timer: any
constructor(props: any) {
super(props)
this.state = { location: 'bottom', keys: [] }
@@ -44,7 +45,7 @@ class Demo extends React.Component<{ classes: any }, State> {
middle: -32,
}
const style = {
position: 'fixed' as 'fixed',
position: 'fixed' as const,
left: '5vw',
zIndex: 1000000,
margin: '30vw auto 50vw',
@@ -52,7 +53,7 @@ class Demo extends React.Component<{ classes: any }, State> {
bottom: `${positions[this.state.location]}vh`,
}
const style2 = {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
fontSize: '4em',
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
@@ -67,9 +68,7 @@ class Demo extends React.Component<{ classes: any }, State> {
if (this.state.keys.length > 0) {
keys = this.state.keys
.map(key => [<Key key={key} keyboardKey={key} />])
.reduce((prev, current) => {
return [prev, '+' as any, current]
})
.reduce((prev, current) => [prev, '+' as any, current])
}
return (
@@ -86,7 +85,7 @@ class Demo extends React.Component<{ classes: any }, State> {
const style = (theme: Theme) => ({
keysStyle: {
fontSize: '1em',
display: 'inline-block' as 'inline-block',
display: 'inline-block' as const,
transform: 'translateY(0.3em) translateX(0.8em)',
},
})

View File

@@ -1,11 +1,12 @@
import * as React from 'react'
import ShowText from './ShowText'
import Mouse from './Mouse'
let heapdump: any
function writeHeapdump(path?: string) {
if (!heapdump) {
//<heapdump = require('heapdump')
// <heapdump = require('heapdump')
}
heapdump.writeSnapshot(path || `${Date.now()}.heapsnapshot`)

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import PersistentStorage from '../utils/PersistentStorage'
import SentimentDissatisfied from '@mui/icons-material/SentimentDissatisfied'
import Warning from '@mui/icons-material/Warning'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Modal, Paper, Toolbar, Typography } from '@mui/material'
import PersistentStorage from '../utils/PersistentStorage'
interface State {
error?: Error
@@ -19,6 +19,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
public static getDerivedStateFromError(error: Error) {
return { error }
}
constructor(props: Props) {
super(props)
this.state = {}
@@ -45,7 +46,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
const { classes } = this.props
return (
<Modal open={true} disableAutoFocus={true}>
<Modal open disableAutoFocus>
<Paper className={classes.root}>
<Toolbar style={{ padding: '0' }}>
<Typography className={classes.title} variant="h6" color="inherit">
@@ -101,17 +102,17 @@ const styles = (theme: Theme) => ({
title: {
color: theme.palette.text.primary,
margin: '0',
textAlign: 'center' as 'center',
textAlign: 'center' as const,
},
textColor: {
color: theme.palette.text.primary,
userSelect: 'all' as 'all',
userSelect: 'all' as const,
},
centered: {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
},
buttonPositioning: {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
marginTop: theme.spacing(2),
},
})

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import ChartPanel from '../ChartPanel'
import ReactSplitPaneImport from 'react-split-pane'
import { connect } from 'react-redux'
import { List } from 'immutable'
import { useResizeDetector } from 'react-resize-detector'
import ChartPanel from '../ChartPanel'
import Tree from '../Tree'
import { AppState } from '../../reducers'
import { ChartParameters } from '../../reducers/Charts'
import { connect } from 'react-redux'
import { List } from 'immutable'
import { Sidebar } from '../Sidebar'
import { useResizeDetector } from 'react-resize-detector'
import MobileTabs from './MobileTabs'
import PublishTab from '../Sidebar/PublishTab'
@@ -92,8 +92,8 @@ function ContentView(props: Props) {
// Expose tab switching functions for other components to call
React.useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).switchToDetailsTab = () => setMobileTab(1)
(window as any).switchToTopicsTab = () => setMobileTab(0)
;(window as any).switchToDetailsTab = () =>
(setMobileTab(1)(window as any).switchToTopicsTab = () => setMobileTab(0))
;(window as any).switchToPublishTab = () => setMobileTab(2)
;(window as any).switchToChartsTab = () => setMobileTab(3)
}
@@ -107,6 +107,19 @@ function ContentView(props: Props) {
}
}, [])
// Scroll to selected topic when returning to tree tab
React.useEffect(() => {
if (mobileTab === 0) {
// Delay to ensure DOM is rendered
setTimeout(() => {
const selectedNode = document.querySelector('.tree .selected')
if (selectedNode) {
selectedNode.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
}
}, [mobileTab])
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
@@ -150,30 +163,22 @@ function ContentView(props: Props) {
<div style={mobileContainerStyle}>
<MobileTabs value={mobileTab} onChange={setMobileTab} />
<div style={tabContentStyle}>
{/* Topics tab */}
{mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{mobileTab === 1 && (
<div style={sidebarContainerStyle}>
<Sidebar connectionId={props.connectionId} />
</div>
)}
{/* Publish tab */}
{mobileTab === 2 && (
<div style={sidebarContainerStyle}>
<PublishTab connectionId={props.connectionId} />
</div>
)}
{/* Charts tab */}
{mobileTab === 3 && (
<div style={sidebarContainerStyle}>
<ChartPanel />
</div>
)}
{/* Topics tab - keep mounted, toggle visibility */}
<div style={{ ...treeContainerStyle, display: mobileTab === 0 ? 'block' : 'none' }}>
<Tree />
</div>
{/* Details tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 1 ? 'block' : 'none' }}>
<Sidebar connectionId={props.connectionId} />
</div>
{/* Publish tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}>
<PublishTab connectionId={props.connectionId} />
</div>
{/* Charts tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 3 ? 'block' : 'none' }}>
<ChartPanel />
</div>
</div>
</div>
)
@@ -192,7 +197,7 @@ function ContentView(props: Props) {
size={sidebarWidth}
onChange={(size: number) => setSidebarWidth(size)}
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
allowResize={true}
allowResize
style={{ height: '100%' }}
pane1Style={{ overflowX: 'hidden' }}
resizerStyle={{ height: '100%' }}
@@ -203,7 +208,7 @@ function ContentView(props: Props) {
split="horizontal"
minSize={0}
size={height}
allowResize={true}
allowResize
style={{ height: 'calc(100vh - 64px)' }}
pane1Style={{ maxHeight: '100%' }}
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
@@ -212,7 +217,15 @@ function ContentView(props: Props) {
>
<Tree />
{/** Passing height constraints via flex options down */}
<div ref={heightRef} style={{ flex: 1, display: 'flex', height: '100%', width: '100%' }}>
<div
ref={heightRef}
style={{
flex: 1,
display: 'flex',
height: '100%',
width: '100%',
}}
>
{/** Resize detector must not be in the scroll zone, it needs to detect actual available size */}
<ChartPanel />
</div>
@@ -225,7 +238,7 @@ function ContentView(props: Props) {
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'
overflowX: 'hidden',
}}
>
<Sidebar connectionId={props.connectionId} />
@@ -237,10 +250,8 @@ function ContentView(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
chartPanelItems: state.charts.get('charts'),
}
}
const mapStateToProps = (state: AppState) => ({
chartPanelItems: state.charts.get('charts'),
})
export default connect(mapStateToProps)(ContentView)

View File

@@ -69,7 +69,7 @@ const styles = (theme: Theme) => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
position: 'relative' as 'relative',
position: 'relative' as const,
zIndex: 1,
minHeight: '56px', // Touch-friendly tab height
'& .MuiTab-root': {
@@ -77,7 +77,7 @@ const styles = (theme: Theme) => ({
fontSize: '16px', // Prevent iOS zoom
fontWeight: 500,
padding: theme.spacing(1.5, 2),
textTransform: 'none' as 'none', // Better readability
textTransform: 'none' as const, // Better readability
'&:active': {
opacity: 0.7, // Touch feedback
},

View File

@@ -30,8 +30,8 @@ class Notification extends React.PureComponent<Props, {}> {
public render() {
const snackbarAnchor = {
vertical: 'bottom' as 'bottom',
horizontal: 'left' as 'left',
vertical: 'bottom' as const,
horizontal: 'left' as const,
}
return (

View File

@@ -1,19 +1,19 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import CustomIconButton from '../helper/CustomIconButton'
import Pause from '@mui/icons-material/PauseCircleFilled'
import Resume from '@mui/icons-material/PlayArrow'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { treeActions } from '../../actions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import * as q from '../../../../backend/src/Model'
import CustomIconButton from '../helper/CustomIconButton'
import { treeActions } from '../../actions'
import { AppState } from '../../reducers'
const styles = (theme: Theme) => ({
icon: {
color: theme.palette.primary.contrastText,
verticalAlign: 'middle' as 'middle',
verticalAlign: 'middle' as const,
},
bufferStats: {
minWidth: '8em',
@@ -31,6 +31,7 @@ interface Props {
class PauseButton extends React.PureComponent<Props, { changes: number }> {
private timer?: any
constructor(props: Props) {
super(props)
this.state = { changes: 0 }
@@ -88,19 +89,15 @@ class PauseButton extends React.PureComponent<Props, { changes: number }> {
}
}
const mapStateToProps = (state: AppState) => {
return {
paused: state.tree.get('paused'),
tree: state.tree.get('tree'),
}
}
const mapStateToProps = (state: AppState) => ({
paused: state.tree.get('paused'),
tree: state.tree.get('tree'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
tree: bindActionCreators(treeActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
tree: bindActionCreators(treeActions, dispatch),
},
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(PauseButton) as any)

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useState, useRef } from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Search from '@mui/icons-material/Search'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { InputBase } from '@mui/material'
import { settingsActions } from '../../actions'
import { alpha as fade, Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import ClearAdornment from '../helper/ClearAdornment'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import { KeyCodes } from '../../utils/KeyCodes'
@@ -28,7 +28,7 @@ function SearchBar(props: {
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
if ((window as any).switchToTopicsTab) {
(window as any).switchToTopicsTab()
;(window as any).switchToTopicsTab()
}
}
}, [])
@@ -90,24 +90,20 @@ function SearchBar(props: {
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
hasConnection: Boolean(state.connection.connectionId),
}
}
const mapStateToProps = (state: AppState) => ({
topicFilter: state.settings.get('topicFilter'),
hasConnection: Boolean(state.connection.connectionId),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
})
const styles = (theme: Theme) => ({
search: {
position: 'relative' as 'relative',
position: 'relative' as const,
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
@@ -122,21 +118,21 @@ const styles = (theme: Theme) => ({
maxWidth: '30%',
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
width: 'auto' as const,
},
[theme.breakpoints.up(750)]: {
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
width: 'auto' as const,
},
},
searchIcon: {
width: theme.spacing(6),
height: '100%',
position: 'absolute' as 'absolute',
pointerEvents: 'none' as 'none',
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
justifyContent: 'center' as 'center',
position: 'absolute' as const,
pointerEvents: 'none' as const,
display: 'flex' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
inputRoot: {
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity

View File

@@ -1,35 +1,36 @@
import * as React from 'react'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
import Menu from '@mui/icons-material/Menu'
import PauseButton from './PauseButton'
import SearchBar from './SearchBar'
import { AppBar, Button, IconButton, Toolbar, Typography } from '@mui/material'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, globalActions, settingsActions } from '../../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionActions, globalActions, settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import SearchBar from './SearchBar'
import PauseButton from './PauseButton'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
const styles = (theme: Theme) => ({
title: {
display: 'none' as 'none',
display: 'none' as const,
[theme.breakpoints.up(750)]: {
display: 'block' as 'block',
display: 'block' as const,
},
[theme.breakpoints.up('md')]: {
display: 'block' as 'block',
display: 'block' as const,
},
whiteSpace: 'nowrap' as 'nowrap',
whiteSpace: 'nowrap' as const,
},
disconnectIcon: {
[theme.breakpoints.down('xs')]: {
display: 'none' as 'none',
display: 'none' as const,
},
marginRight: '8px',
paddingLeft: '8px',
@@ -42,14 +43,14 @@ const styles = (theme: Theme) => ({
margin: 'auto 8px auto auto',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
logout: {
margin: 'auto 0 auto 8px',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
disconnectLabel: {
@@ -117,7 +118,7 @@ class TitleBar extends React.PureComponent<Props, {}> {
Disconnect <CloudOff className={classes.disconnectIcon} />
</Button>
<LogoutButton classes={classes} onLogout={this.handleLogout} />
<ConnectionHealthIndicatorAny withBackground={true} />
<ConnectionHealthIndicatorAny withBackground />
</Toolbar>
</AppBar>
)
@@ -133,30 +134,22 @@ function LogoutButton({ classes, onLogout }: { classes: any; onLogout: () => voi
}
return (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={onLogout}
>
<Button className={classes.logout} sx={{ color: 'primary.contrastText' }} onClick={onLogout}>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
}
}
const mapStateToProps = (state: AppState) => ({
topicFilter: state.settings.get('topicFilter'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(TitleBar))

View File

@@ -27,10 +27,7 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error is visible to user
const errorElement = getByText(errorMessage)
@@ -41,10 +38,7 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify rate limiting error is visible to user
expect(getByText('Too many failed authentication attempts')).to.exist
@@ -54,10 +48,7 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Please enter your username and password.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify auth required message is visible to user
expect(getByText('Please enter your username and password.')).to.exist
@@ -67,10 +58,7 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Authentication failed. Please try again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify generic error is visible to user
expect(getByText('Authentication failed. Please try again.')).to.exist
@@ -82,10 +70,9 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify button is disabled to prevent further attempts
const buttons = Array.from(document.querySelectorAll('button'))
@@ -98,10 +85,9 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify inputs are disabled to prevent modification during lockout
const usernameInput = getByTestId('username-input')?.querySelector('input')
@@ -115,10 +101,9 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify countdown is visible to inform user of lockout duration
const countdownElement = getByText('Please wait')
@@ -131,10 +116,9 @@ describe('LoginDialog Security Tests', () => {
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify both error and countdown are visible
expect(getByText(errorMessage)).to.exist
@@ -146,10 +130,7 @@ describe('LoginDialog Security Tests', () => {
it('should require both username and password fields to be present', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} />, { withTheme: true })
// Verify both credential fields exist and are required
const usernameInput = getByTestId('username-input')
@@ -162,10 +143,7 @@ describe('LoginDialog Security Tests', () => {
it('should require password field to be masked', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} />, { withTheme: true })
// Verify password is masked (type="password") for security
const passwordInput = getByTestId('password-input')?.querySelector('input')
@@ -179,10 +157,7 @@ describe('LoginDialog Security Tests', () => {
// Error doesn't distinguish between invalid username vs invalid password
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error doesn't leak whether username or password was wrong
const errorElement = getByText(errorMessage)
@@ -195,10 +170,7 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error doesn't contain sensitive data
const errorElement = getByText(errorMessage)

View File

@@ -57,7 +57,15 @@ export function LoginDialog(props: LoginDialogProps) {
const isDisabled = countdown !== undefined && countdown > 0
return (
<Dialog open={props.open} disableEscapeKeyDown onClose={(event, reason) => { if (reason !== 'backdropClick') { /* Allow closing only via escape if needed */ } }}>
<Dialog
open={props.open}
disableEscapeKeyDown
onClose={(event, reason) => {
if (reason !== 'backdropClick') {
/* Allow closing only via escape if needed */
}
}}
>
<DialogTitle>Login to MQTT Explorer</DialogTitle>
<DialogContent>
{props.error && (

View File

@@ -1,9 +1,9 @@
import * as React from 'react'
import { TextField, MenuItem, Tooltip } from '@mui/material'
import { QoS } from '../../../backend/src/DataSource/MqttSource'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void; label?: string }) {
const tooltipStyle = { textAlign: 'center' as 'center', width: '100%' }
const tooltipStyle = { textAlign: 'center' as const, width: '100%' }
const itemStyle = { padding: '0' }
const onChangeQos = React.useCallback(
@@ -19,7 +19,7 @@ export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void
return (
<TextField
select={true}
select
label={props.label}
value={props.selected}
margin="normal"

View File

@@ -1,9 +1,17 @@
import * as React from 'react'
import { InputLabel, Switch, Theme, Tooltip } from '@mui/material'
import { withStyles } from '@mui/styles'
const sha1 = require('sha1')
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any; 'data-testid'?: string }) {
function BooleanSwitch(props: {
title: string
value: boolean
tooltip: string
action: () => void
classes: any
'data-testid'?: string
}) {
const { tooltip, value, action, title, classes } = props
const clickHandler = (e: React.MouseEvent) => {

View File

@@ -1,14 +1,15 @@
import * as q from '../../../../backend/src/Model'
import React, { useMemo } from 'react'
import { AppState } from '../../reducers'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { connect } from 'react-redux'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { TopicViewModel } from '../../model/TopicViewModel'
import { AppState } from '../../reducers'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
import { useUpdateComponentWhenNodeUpdates } from '../helper/useUpdateComponentWhenNodeUpdates'
const abbreviate = require('number-abbreviate')
interface Stats {
@@ -37,7 +38,7 @@ function BrokerStatistics(props: Props) {
useUpdateComponentWhenNodeUpdates(sysTopic)
return useMemo(() => {
if (!Boolean(sysTopic)) {
if (!sysTopic) {
return null
}
@@ -96,11 +97,9 @@ function BrokerStatistics(props: Props) {
}, [sysTopic && sysTopic.lastUpdate, props.classes])
}
const mapStateToProps = (state: AppState) => {
return {
tree: state.connection.tree,
}
}
const mapStateToProps = (state: AppState) => ({
tree: state.connection.tree,
})
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))

View File

@@ -1,21 +1,12 @@
import * as React from 'react'
import BooleanSwitch from './BooleanSwitch'
import BrokerStatistics from './BrokerStatistics'
import ChevronRight from '@mui/icons-material/ChevronRight'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { shell } from 'electron'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import {
Button,
Divider,
@@ -29,6 +20,15 @@ import {
Typography,
Tooltip,
} from '@mui/material'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import BrokerStatistics from './BrokerStatistics'
import BooleanSwitch from './BooleanSwitch'
export const autoExpandLimitSet = [
{
@@ -61,7 +61,7 @@ const styles = (theme: Theme) => ({
drawer: {
backgroundColor: theme.palette.background.default,
flexShrink: 0,
userSelect: 'none' as 'none',
userSelect: 'none' as const,
},
paper: {
width: '300px',
@@ -78,16 +78,16 @@ const styles = (theme: Theme) => ({
author: {
margin: 'auto 8px 8px auto',
color: theme.palette.text.secondary,
cursor: 'pointer' as 'pointer',
cursor: 'pointer' as const,
},
mobileButtons: {
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1),
// Only show on mobile
[theme.breakpoints.up('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
mobileButton: {
@@ -205,7 +205,7 @@ class Settings extends React.PureComponent<Props, {}> {
value={topicOrder}
onChange={this.onChangeSorting}
input={<Input name="node-order" id="node-order-label-placeholder" />}
displayEmpty={true}
displayEmpty
name="node-order"
className={classes.input}
style={{ flex: '1' }}
@@ -304,25 +304,21 @@ function MobileActionButtons({ classes, actions }: { classes: any; actions: any
)
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.get('autoExpandLimit'),
topicOrder: state.settings.get('topicOrder'),
visible: state.globalState.get('settingsVisible'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
selectTopicWithMouseOver: state.settings.get('selectTopicWithMouseOver'),
theme: state.settings.get('theme'),
}
}
const mapStateToProps = (state: AppState) => ({
autoExpandLimit: state.settings.get('autoExpandLimit'),
topicOrder: state.settings.get('topicOrder'),
visible: state.globalState.get('settingsVisible'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
selectTopicWithMouseOver: state.settings.get('selectTopicWithMouseOver'),
theme: state.settings.get('theme'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
})
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Settings))

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import DateFormatter from '../helper/DateFormatter'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Input, InputLabel, MenuItem, Select, Theme } from '@mui/material'
import { settingsActions } from '../../actions'
import { withStyles } from '@mui/styles'
import { settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import DateFormatter from '../helper/DateFormatter'
function importAll(r: any) {
r.keys().forEach(r)
@@ -63,19 +63,15 @@ function TimeLocaleSettings(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
timeLocale: state.settings.get('timeLocale'),
}
}
const mapStateToProps = (state: AppState) => ({
timeLocale: state.settings.get('timeLocale'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
})
const styles = (theme: Theme) => ({
input: {

View File

@@ -15,14 +15,9 @@ import {
Collapse,
Alert,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
CardActions,
} from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
@@ -30,12 +25,16 @@ import SendIcon from '@mui/icons-material/Send'
import SmartToyIcon from '@mui/icons-material/SmartToy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SettingsIcon from '@mui/icons-material/Settings'
import ClearIcon from '@mui/icons-material/Clear'
import { getLLMService, LLMMessage, LLMProvider } from '../../services/llmService'
import PublishIcon from '@mui/icons-material/Publish'
import BugReportIcon from '@mui/icons-material/BugReport'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { getLLMService, LLMMessage, MessageProposal, QuestionProposal } from '../../services/llmService'
import { makePublishEvent, rendererEvents } from '../../eventBus'
interface Props {
node?: any
connectionId?: string
classes: any
}
@@ -43,25 +42,24 @@ interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: Date
proposals?: MessageProposal[]
questionProposals?: QuestionProposal[]
debugInfo?: any // API debug information
}
function AIAssistant(props: Props) {
const { node, classes } = props
const { node, connectionId, classes } = props
const [expanded, setExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [configDialogOpen, setConfigDialogOpen] = useState(false)
const [apiKey, setApiKey] = useState('')
const [provider, setProvider] = useState<LLMProvider>('openai')
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const llmService = getLLMService()
useEffect(() => {
// Initialize provider from service
setProvider(llmService.getProvider())
}, [])
const previousNodePathRef = useRef<string>('')
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -71,15 +69,40 @@ function AIAssistant(props: Props) {
scrollToBottom()
}, [messages])
// Auto-generate questions when node changes or chat is expanded
// Clear chat when node changes
useEffect(() => {
const nodePath = node?.path?.()
if (expanded && node && nodePath && nodePath !== previousNodePathRef.current && llmService.hasApiKey()) {
previousNodePathRef.current = nodePath
// Clear chat messages and error when switching to a new topic
setMessages([])
setError(null)
setLoadingSuggestions(true)
llmService
.generateSuggestedQuestions(node)
.then(questions => {
setSuggestedQuestions(questions)
})
.catch(err => {
console.error('Failed to generate suggested questions:', err)
})
.finally(() => {
setLoadingSuggestions(false)
})
}
}, [expanded, node, llmService])
const handleSendMessage = useCallback(
async (messageText?: string) => {
const text = messageText || inputValue.trim()
if (!text) return
// Check if API key is configured
// Check if backend LLM service is available
if (!llmService.hasApiKey()) {
setError(`Please configure your ${provider === 'gemini' ? 'Gemini' : 'OpenAI'} API key first`)
setConfigDialogOpen(true)
setError('LLM service not configured on server. Please contact your administrator.')
return
}
@@ -93,25 +116,33 @@ function AIAssistant(props: Props) {
content: text,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setMessages(prev => [...prev, userMessage])
try {
// Generate topic context if available
const topicContext = node ? llmService.generateTopicContext(node) : undefined
// Send to LLM
const response = await llmService.sendMessage(text, topicContext)
// Send to LLM - now returns { response, debugInfo }
const llmResponse = await llmService.sendMessage(text, topicContext)
// Add assistant response to UI
// Parse response for proposals and questions
const parsed = llmService.parseResponse(llmResponse.response)
// Add assistant response to UI with proposals, questions, and debug info
const assistantMessage: ChatMessage = {
role: 'assistant',
content: response,
content: parsed.text,
timestamp: new Date(),
proposals: parsed.proposals,
questionProposals: parsed.questions,
debugInfo: llmResponse.debugInfo, // Store debug info
}
setMessages((prev) => [...prev, assistantMessage])
setMessages(prev => [...prev, assistantMessage])
} catch (err: unknown) {
const error = err as { message?: string }
setError(error.message || 'Failed to get response')
console.error('AI Assistant error:', err)
console.error('Error details:', error)
setError(error.message || 'Failed to get response from AI assistant')
} finally {
setLoading(false)
}
@@ -136,24 +167,47 @@ function AIAssistant(props: Props) {
setError(null)
}
const handleSaveApiKey = () => {
if (apiKey.trim()) {
llmService.saveApiKey(apiKey.trim())
llmService.saveProvider(provider)
setConfigDialogOpen(false)
setApiKey('')
setError(null)
// Reset the service to use new config
window.location.reload()
}
}
const handlePublishProposal = useCallback(
(proposal: MessageProposal) => {
if (!connectionId) {
setError('No active connection to publish message')
return
}
try {
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic: proposal.topic,
payload: Base64Message.fromString(proposal.payload),
retain: false,
qos: proposal.qos,
}
rendererEvents.emit(publishEvent, mqttMessage)
// Show success feedback
setMessages(prev => [
...prev,
{
role: 'assistant',
content: `✓ Published to ${proposal.topic}`,
timestamp: new Date(),
},
])
} catch (err) {
setError(`Failed to publish message: ${err}`)
}
},
[connectionId]
)
const suggestions = node ? llmService.getQuickSuggestions(node) : []
const allSuggestions = [...suggestions, ...suggestedQuestions]
// Check if API key is available (from localStorage or environment)
// Check if backend LLM service is available
const hasApiKey = llmService.hasApiKey()
// Don't render the component at all if no API key is available
// Hide component completely if backend doesn't have LLM configured (new requirement)
if (!hasApiKey) {
return null
}
@@ -161,8 +215,8 @@ function AIAssistant(props: Props) {
return (
<Box className={classes.root}>
{/* Header */}
<Box className={classes.header} onClick={() => setExpanded(!expanded)}>
<Box className={classes.headerLeft}>
<Box className={classes.header}>
<Box className={classes.headerLeft} onClick={() => setExpanded(!expanded)} style={{ flex: 1, cursor: 'pointer' }}>
<SmartToyIcon className={classes.icon} />
<Typography variant="subtitle2" className={classes.title}>
AI Assistant
@@ -170,17 +224,22 @@ function AIAssistant(props: Props) {
<Chip label="Beta" size="small" color="primary" className={classes.betaChip} />
</Box>
<Box className={classes.headerRight}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setConfigDialogOpen(true)
}}
className={classes.iconButton}
>
<SettingsIcon fontSize="small" />
</IconButton>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
{expanded && messages.length > 0 && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setShowDebug(!showDebug)
}}
title="Toggle debug view"
className={classes.iconButton}
>
<BugReportIcon fontSize="small" style={{ color: showDebug ? '#f50057' : 'inherit' }} />
</IconButton>
)}
<Box onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
</Box>
@@ -194,20 +253,21 @@ function AIAssistant(props: Props) {
</Alert>
)}
{/* Quick Suggestions */}
{messages.length === 0 && suggestions.length > 0 && (
{/* Quick Suggestions - Always shown when available */}
{hasApiKey && allSuggestions.length > 0 && (
<Box className={classes.suggestions}>
<Typography variant="caption" color="textSecondary" className={classes.suggestionsTitle}>
Quick questions:
{loadingSuggestions ? 'Generating questions...' : 'Suggested questions:'}
</Typography>
<Box className={classes.suggestionChips}>
{suggestions.slice(0, 4).map((suggestion, idx) => (
{allSuggestions.slice(0, 6).map((suggestion, idx) => (
<Chip
key={idx}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
className={classes.suggestionChip}
disabled={loadingSuggestions}
/>
))}
</Box>
@@ -226,16 +286,81 @@ function AIAssistant(props: Props) {
)}
{messages.map((msg, idx) => (
<Box
key={idx}
className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}
>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
<Box key={idx}>
<Box className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
</Box>
{/* Render proposals if any */}
{msg.proposals && msg.proposals.length > 0 && (
<Box className={classes.proposalsContainer}>
{msg.proposals.map((proposal, pIdx) => (
<Card key={pIdx} className={classes.proposalCard} variant="outlined">
<CardContent className={classes.proposalContent}>
<Typography variant="caption" color="primary" fontWeight="bold">
Proposed Action
</Typography>
<Typography variant="body2" gutterBottom>
{proposal.description}
</Typography>
<Box className={classes.proposalDetails}>
<Typography variant="caption" color="textSecondary">
Topic: <code>{proposal.topic}</code>
</Typography>
<Typography variant="caption" color="textSecondary">
Payload: <code>{proposal.payload}</code>
</Typography>
</Box>
</CardContent>
<CardActions className={classes.proposalActions}>
<Button
size="small"
variant="contained"
color="primary"
startIcon={<PublishIcon />}
onClick={() => handlePublishProposal(proposal)}
disabled={!connectionId}
>
Send Message
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Render question proposals if any */}
{msg.questionProposals && msg.questionProposals.length > 0 && (
<Box className={classes.questionProposalsContainer}>
<Typography variant="caption" color="textSecondary" gutterBottom>
Follow-up questions:
</Typography>
<Box className={classes.suggestionChips}>
{msg.questionProposals.map((qProposal, qIdx) => (
<Chip
key={qIdx}
label={qProposal.question}
size="small"
color="secondary"
onClick={() => handleSuggestionClick(qProposal.question)}
className={classes.questionProposalChip}
icon={
qProposal.category ? (
<Typography variant="caption" component="span">
{qProposal.category}:
</Typography>
) : undefined
}
/>
))}
</Box>
</Box>
)}
</Box>
))}
@@ -251,6 +376,68 @@ function AIAssistant(props: Props) {
<div ref={messagesEndRef} />
</Box>
{/* Debug View - Enhanced with API Traffic */}
{showDebug && messages.length > 0 && (
<Paper className={classes.debugContainer} variant="outlined">
<Typography variant="caption" fontWeight="bold" color="textSecondary" gutterBottom>
Debug: Complete API Traffic
</Typography>
<Box className={classes.debugContent}>
<pre className={classes.debugPre}>
{JSON.stringify(
{
systemMessage: {
role: 'system',
content: llmService.getSystemMessage(),
note: 'This is the system prompt that provides context to the LLM',
},
messages: messages.map((msg, idx) => ({
index: idx,
role: msg.role,
content: msg.content.substring(0, 200) + (msg.content.length > 200 ? '...' : ''),
fullContent: msg.content,
hasTopicContext: msg.role === 'user' && msg.content.startsWith('Context:'),
contentLength: msg.content.length,
timestamp: msg.timestamp.toISOString(),
proposals: msg.proposals?.length || 0,
questionProposals: msg.questionProposals?.length || 0,
...(msg.debugInfo && {
apiDebug: {
provider: msg.debugInfo.provider,
model: msg.debugInfo.model,
timing: msg.debugInfo.timing,
request: {
url: msg.debugInfo.request?.url,
body: msg.debugInfo.request?.body,
},
response: {
id: msg.debugInfo.response?.id,
model: msg.debugInfo.response?.model,
created: msg.debugInfo.response?.created,
choices: msg.debugInfo.response?.choices,
usage: msg.debugInfo.response?.usage,
system_fingerprint: msg.debugInfo.response?.system_fingerprint,
},
},
}),
})),
summary: {
totalMessages: messages.length,
messagesWithDebugInfo: messages.filter(m => m.debugInfo).length,
lastApiCall: messages
.filter(m => m.debugInfo)
.pop()
?.debugInfo?.timing?.timestamp,
},
},
null,
2
)}
</pre>
</Box>
</Paper>
)}
{/* Input */}
<Box className={classes.inputContainer}>
{messages.length > 0 && (
@@ -263,7 +450,7 @@ function AIAssistant(props: Props) {
size="small"
placeholder="Ask about this topic..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={e => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
className={classes.input}
@@ -281,60 +468,6 @@ function AIAssistant(props: Props) {
</Box>
</Box>
</Collapse>
{/* Configuration Dialog */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>AI Assistant Configuration</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>AI Provider</InputLabel>
<Select
value={provider}
label="AI Provider"
onChange={(e) => setProvider(e.target.value as LLMProvider)}
>
<MenuItem value="openai">OpenAI (GPT-3.5 Turbo)</MenuItem>
<MenuItem value="gemini">Google Gemini (Flash)</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" color="textSecondary" paragraph sx={{ mt: 2 }}>
{provider === 'openai' ? (
<>
Get your OpenAI API key from{' '}
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
OpenAI's platform
</a>
.
</>
) : (
<>
Get your Gemini API key from{' '}
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer">
Google AI Studio
</a>
.
</>
)}
</Typography>
<TextField
fullWidth
label={`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API Key`}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'openai' ? 'sk-...' : 'AIza...'}
margin="normal"
helperText="Your API key is stored locally and never sent to our servers"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveApiKey} variant="contained" disabled={!apiKey.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -383,7 +516,7 @@ const styles = (theme: Theme) => ({
content: {
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1.5),
},
alert: {
@@ -398,7 +531,7 @@ const styles = (theme: Theme) => ({
},
suggestionChips: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
gap: theme.spacing(0.5),
},
suggestionChip: {
@@ -410,14 +543,14 @@ const styles = (theme: Theme) => ({
},
messages: {
maxHeight: '300px',
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1),
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
@@ -445,8 +578,8 @@ const styles = (theme: Theme) => ({
borderBottomLeftRadius: theme.spacing(0.5),
},
messageText: {
whiteSpace: 'pre-wrap' as 'pre-wrap',
wordBreak: 'break-word' as 'break-word',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-word' as const,
},
messageTime: {
display: 'block',
@@ -472,6 +605,70 @@ const styles = (theme: Theme) => ({
sendButton: {
padding: theme.spacing(1),
},
proposalsContainer: {
marginTop: theme.spacing(1),
marginLeft: theme.spacing(6),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(1),
},
proposalCard: {
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
borderColor: theme.palette.primary.main,
},
proposalContent: {
paddingBottom: theme.spacing(1),
'&:last-child': {
paddingBottom: theme.spacing(1),
},
},
proposalDetails: {
marginTop: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(0.5),
'& code': {
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(0.25, 0.5),
borderRadius: theme.shape.borderRadius,
fontSize: '0.85em',
},
},
proposalActions: {
padding: theme.spacing(1, 2),
paddingTop: 0,
},
questionProposalsContainer: {
marginTop: theme.spacing(1),
marginLeft: theme.spacing(6),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(0.5),
},
questionProposalChip: {
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.secondary.dark,
},
},
debugContainer: {
marginTop: theme.spacing(1),
padding: theme.spacing(1.5),
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.03)',
maxHeight: '200px',
overflow: 'auto',
},
debugContent: {
marginTop: theme.spacing(0.5),
},
debugPre: {
margin: 0,
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-word' as const,
color: theme.palette.text.secondary,
},
})
export default withStyles(styles)(AIAssistant)

View File

@@ -1,12 +1,12 @@
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import ShowChart from '@mui/icons-material/ShowChart'
import TopicPlot from '../../TopicPlot'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { connect } from 'react-redux'
import { Fade, Paper, Popper, Tooltip } from '@mui/material'
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
import * as q from '../../../../../backend/src/Model'
import { chartActions } from '../../../actions'
import TopicPlot from '../../TopicPlot'
interface Props {
treeNode: q.TreeNode<any>
@@ -79,12 +79,10 @@ function ChartPreview(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)(ChartPreview)

View File

@@ -22,11 +22,13 @@ function changeAmount(props: Props) {
<span>
Comparing with <b>{props.nameOfCompareMessage}</b> message:&nbsp;
<span className={props.classes.additions}>
+ {additions} line{additions === 1 ? '' : 's'}
+ {additions} line
{additions === 1 ? '' : 's'}
</span>
,{' '}
<span className={props.classes.deletions}>
- {deletions} line{deletions === 1 ? '' : 's'}
- {deletions} line
{deletions === 1 ? '' : 's'}
</span>
</span>
</span>

View File

@@ -1,13 +1,13 @@
import * as diff from 'diff'
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import ChartPreview from './ChartPreview'
import Remove from '@mui/icons-material/Remove'
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
import { lineChangeStyle, trimNewlineRight } from './util'
import { Theme } from '@mui/material'
import { withStyles } from '@mui/styles'
import * as q from '../../../../../backend/src/Model'
import { lineChangeStyle, trimNewlineRight } from './util'
import ChartPreview from './ChartPreview'
interface Props {
changes: Array<diff.Change>
@@ -40,7 +40,7 @@ const style = (theme: Theme) => {
},
},
gutterLine: {
textAlign: 'right' as 'right',
textAlign: 'right' as const,
paddingRight: theme.spacing(0.5),
height: '16px',
width: '100%',
@@ -52,7 +52,7 @@ function tokensForLine(change: diff.Change, line: number, props: Props) {
const { classes, literalPositions } = props
const literal = literalPositions[line]
const chartPreview = Boolean(literal) ? (
const chartPreview = literal ? (
<ChartPreview
key="chartPreview"
treeNode={props.treeNode}
@@ -63,25 +63,25 @@ function tokensForLine(change: diff.Change, line: number, props: Props) {
if (change.added) {
return [chartPreview, <Add key="add" className={classes.icon} />]
} else if (change.removed) {
return [<Remove key="remove" className={classes.icon} />]
} else {
return [
chartPreview,
<div
key="placeholder"
style={{ width: '12px', display: 'inline-block' }}
dangerouslySetInnerHTML={{ __html: '&nbsp;' }}
/>,
]
}
if (change.removed) {
return [<Remove key="remove" className={classes.icon} />]
}
return [
chartPreview,
<div
key="placeholder"
style={{ width: '12px', display: 'inline-block' }}
dangerouslySetInnerHTML={{ __html: '&nbsp;' }}
/>,
]
}
function Gutters(props: Props) {
let currentLine = -1
const gutters = props.changes
.map((change, key) => {
return trimNewlineRight(change.value)
.map((change, key) =>
trimNewlineRight(change.value)
.split('\n')
.map((_, idx) => {
currentLine = !change.removed ? currentLine + 1 : currentLine
@@ -91,7 +91,7 @@ function Gutters(props: Props) {
</div>
)
})
})
)
.reduce((a, b) => a.concat(b), [])
return (

View File

@@ -1,15 +1,15 @@
import * as diff from 'diff'
import * as Prism from 'prismjs'
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import * as q from '../../../../../backend/src/Model'
import DiffCount from './DiffCount'
import Gutters from './Gutters'
import { isPlottable, lineChangeStyle, trimNewlineRight } from './util'
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
import { selectTextWithCtrlA } from '../../../utils/handleTextSelectWithCtrlA'
import { style } from './style'
import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import 'prismjs/components/prism-json'
interface Props {
@@ -59,13 +59,11 @@ class CodeDiff extends React.PureComponent<Props, State> {
const changedLines = change.count || 0
if (hasStyledCode && this.props.language === 'json') {
const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines)
const lines = currentLines.map((html: string, idx: number) => {
return (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
const lines = currentLines.map((html: string, idx: number) => (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span dangerouslySetInnerHTML={{ __html: html }} />
</div>
))
lineNumber += changedLines
return [<div key={key}>{lines}</div>]
@@ -73,13 +71,11 @@ class CodeDiff extends React.PureComponent<Props, State> {
return trimNewlineRight(change.value)
.split('\n')
.map((line, idx) => {
return (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span>{line}</span>
</div>
)
})
.map((line, idx) => (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span>{line}</span>
</div>
))
})
.reduce((a, b) => a.concat(b), [])
}

View File

@@ -1,11 +1,11 @@
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
import { Theme } from '@mui/material'
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
export const style = (theme: Theme) => {
const codeBlockColors = theme.palette.mode === 'light' ? CodeBlockColors : CodeBlockColorsBraceMonokai
const codeBaseStyle = {
font: "12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace",
display: 'inline-grid' as 'inline-grid',
display: 'inline-grid' as const,
margin: '0',
padding: '1px 0 0 0',
}
@@ -17,7 +17,7 @@ export const style = (theme: Theme) => {
backgroundColor: codeBlockColors.gutters,
},
line: {
lineHeight: 'normal' as 'normal',
lineHeight: 'normal' as const,
paddingLeft: '4px',
width: '100%',
height: '16px',
@@ -32,7 +32,7 @@ export const style = (theme: Theme) => {
...codeBaseStyle,
width: '33px',
backgroundColor: codeBlockColors.gutters,
userSelect: 'none' as 'none',
userSelect: 'none' as const,
},
codeBlock: {
...codeBaseStyle,

View File

@@ -56,7 +56,7 @@ export function toPlottableValue(value: any): number | undefined {
return value.toLowerCase() === 'on' ? 1 : 0
}
if (/^[0-9]*,[0-9]+$/.test(value)) {
let parsedFloat = parseFloat(value.replace(',', '.'))
const parsedFloat = parseFloat(value.replace(',', '.'))
if (!isNaN(parsedFloat)) {
return parsedFloat
}

View File

@@ -1,11 +1,14 @@
import * as q from '../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { Box, Typography, IconButton, Chip, Tooltip, Button } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { AppState } from '../../reducers'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import * as q from '../../../../backend/src/Model'
import { AppState } from '../../reducers'
import { sidebarActions, globalActions } from '../../actions'
import Copy from '../helper/Copy'
import Save from '../helper/Save'
@@ -15,9 +18,6 @@ import MessageHistory from './ValueRenderer/MessageHistory'
import ActionButtons from './ValueRenderer/ActionButtons'
import DeleteSelectedTopicButton from './ValueRenderer/DeleteSelectedTopicButton'
import { useDecoder } from '../hooks/useDecoder'
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import SimpleBreadcrumb from './SimpleBreadcrumb'
import AIAssistant from './AIAssistant'
@@ -25,6 +25,7 @@ interface Props {
node?: q.TreeNode<any>
classes: any
compareMessage?: q.Message
connectionId?: string
sidebarActions: typeof sidebarActions
globalActions: typeof globalActions
}
@@ -33,9 +34,10 @@ function DetailsTab(props: Props) {
const { node, compareMessage, classes } = props
const decodeMessage = useDecoder(node)
const getDecodedValue = useCallback(() => {
return node?.message && decodeMessage(node.message)?.message?.toUnicodeString()
}, [node, decodeMessage])
const getDecodedValue = useCallback(
() => node?.message && decodeMessage(node.message)?.message?.toUnicodeString(),
[node, decodeMessage]
)
const getData = () => {
if (node?.message && node.message.payload) {
@@ -129,12 +131,7 @@ function DetailsTab(props: Props) {
{node.message?.retain && (
<Chip label="Retained" size="small" variant="outlined" color="primary" className={classes.chip} />
)}
<Chip
label={`QoS ${node.message?.qos ?? 0}`}
size="small"
variant="outlined"
className={classes.chip}
/>
<Chip label={`QoS ${node.message?.qos ?? 0}`} size="small" variant="outlined" className={classes.chip} />
</Box>
</Box>
@@ -198,7 +195,7 @@ function DetailsTab(props: Props) {
)}
{/* AI Assistant - Always available when a node is selected */}
{node && <AIAssistant node={node} />}
{node && <AIAssistant node={node} connectionId={props.connectionId} />}
{/* About Section - always visible at bottom */}
<Box className={classes.aboutSection}>
@@ -219,7 +216,7 @@ function DetailsTab(props: Props) {
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(3),
[theme.breakpoints.down('sm')]: {
gap: theme.spacing(2),
@@ -227,7 +224,7 @@ const styles = (theme: Theme) => ({
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
@@ -276,7 +273,7 @@ const styles = (theme: Theme) => ({
},
statItem: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
padding: theme.spacing(1.5, 1),
backgroundColor: theme.palette.action.hover,
@@ -286,7 +283,7 @@ const styles = (theme: Theme) => ({
statLabel: {
fontSize: '0.75rem',
fontWeight: 500,
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
},
statValue: {
@@ -297,7 +294,7 @@ const styles = (theme: Theme) => ({
// Value section
valueSection: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(2),
},
metadataBar: {
@@ -305,7 +302,7 @@ const styles = (theme: Theme) => ({
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
padding: theme.spacing(1),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
@@ -314,7 +311,7 @@ const styles = (theme: Theme) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
},
metadataRight: {
display: 'flex',
@@ -328,13 +325,13 @@ const styles = (theme: Theme) => ({
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
},
valueTitle: {
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
flexShrink: 0,
},
@@ -356,17 +353,13 @@ const styles = (theme: Theme) => ({
},
})
const mapStateToProps = (state: AppState) => {
return {
compareMessage: state.sidebar.get('compareMessage'),
}
}
const mapStateToProps = (state: AppState) => ({
compareMessage: state.sidebar.get('compareMessage'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(DetailsTab))

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useState, useEffect, memo } from 'react'
import { Badge, Typography } from '@mui/material'
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
import { Theme, emphasize } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
interface HistoryItem {
key: string
@@ -81,7 +81,7 @@ function HistoryDrawer(props: Props) {
borderBottom: expanded ? `1px solid ${emphasize(props.theme.palette.background.default, 0.2)}` : undefined,
}}
>
<Typography component={'span'} onClick={toggle} style={{ cursor: 'pointer', display: 'flex' }}>
<Typography component="span" onClick={toggle} style={{ cursor: 'pointer', display: 'flex' }}>
<span style={{ flexGrow: 1 }}>
<Badge
classes={{ badge: props.classes.badge }}

View File

@@ -1,8 +1,8 @@
import React, { memo } from 'react'
import { Message } from '../../../../backend/src/Model'
import { Tooltip } from '@mui/material'
import { Message } from '../../../../backend/src/Model'
export const MessageId = memo(function MessageId(props: { message: Message; addComma?: boolean }) {
export const MessageId = memo((props: { message: Message; addComma?: boolean }) => {
const { message, addComma } = props
if (!message.messageId) {

View File

@@ -1,7 +1,7 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { TopicViewModel } from '../../model/TopicViewModel'
interface Props {
node?: q.TreeNode<TopicViewModel>
@@ -21,7 +21,10 @@ class NodeStats extends React.Component<Props, {}> {
return (
<div>
<Typography>Messages: #{node.messages}</Typography>
<Typography>Subtopics: {node.childTopicCount()}</Typography>
<Typography>
Subtopics:
{node.childTopicCount()}
</Typography>
<Typography>Messages Subtopics: #{node.leafMessageCount()}</Typography>
</div>
)

View File

@@ -12,14 +12,14 @@ const styles = (theme: Theme) => ({
},
})
const Panel = (props: {
function Panel(props: {
classes: any
children: [React.ReactElement, React.ReactElement]
disabled?: boolean
detailsHidden?: boolean
}) => {
}) {
return (
<Accordion defaultExpanded={true} disabled={props.disabled}>
<Accordion defaultExpanded disabled={props.disabled}>
<AccordionSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
<Typography className={props.classes.heading}>{props.children[0]}</Typography>
</AccordionSummary>

View File

@@ -9,7 +9,6 @@ import 'ace-builds/src-noconflict/snippets/json'
import 'ace-builds/src-noconflict/snippets/xml'
import 'ace-builds/src-noconflict/mode-text'
import 'ace-builds/src-noconflict/theme-monokai'
import 'react-ace'
function Editor(props: {
editorMode: string
@@ -32,11 +31,11 @@ function Editor(props: {
name="UNIQUE_ID_OF_DIV"
width="100%"
height="200px"
enableSnippets={true}
enableBasicAutocompletion={true}
enableLiveAutocompletion={true}
enableSnippets
enableBasicAutocompletion
enableLiveAutocompletion
showPrintMargin={false}
showGutter={true}
showGutter
value={props.value}
onChange={props.onChange}
setOptions={editorOptions}

View File

@@ -14,7 +14,7 @@ export function EditorModeSelect(props: Props) {
value={props.value}
onFocus={props.focusEditor}
onChange={props.onChange}
row={true}
row
>
<FormControlLabel
value="text"

View File

@@ -1,19 +1,19 @@
import Editor from './Editor'
import { AttachFileOutlined, FormatAlignLeft } from '@mui/icons-material'
import Message from './Model/Message'
import Navigation from '@mui/icons-material/Navigation'
import PublishHistory from './PublishHistory'
import React, { useCallback, useMemo, useState, useRef, memo } from 'react'
import RetainSwitch from './RetainSwitch'
import TopicInput from './TopicInput'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { Button, Fab, Tooltip } from '@mui/material'
import { connect } from 'react-redux'
import { default as AceEditor } from 'react-ace'
import Editor from './Editor'
import Message from './Model/Message'
import PublishHistory from './PublishHistory'
import RetainSwitch from './RetainSwitch'
import TopicInput from './TopicInput'
import { AppState } from '../../../reducers'
import { EditorModeSelect } from './EditorModeSelect'
import { globalActions, publishActions } from '../../../actions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { default as AceEditor } from 'react-ace'
interface Props {
connectionId?: string
@@ -56,7 +56,7 @@ function Publish(props: Props) {
props.actions.publish(props.connectionId)
const topic = props.topic || ''
const payload = props.payload
const { payload } = props
if (props.connectionId && topic) {
amendToHistory(topic, payload)
}
@@ -101,87 +101,85 @@ function Publish(props: Props) {
)
}
const EditorMode = memo(function EditorMode(props: {
payload?: string
editorMode: string
focusEditor: () => void
actions: typeof publishActions
globalActions: typeof globalActions
publish: () => void
}) {
const updatePayload = props.actions.setPayload
const EditorMode = memo(
(props: {
payload?: string
editorMode: string
focusEditor: () => void
actions: typeof publishActions
globalActions: typeof globalActions
publish: () => void
}) => {
const updatePayload = props.actions.setPayload
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
props.actions.setEditorMode(value)
}, [])
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
props.actions.setEditorMode(value)
}, [])
const openFile = useCallback(() => {
props.actions.openFile()
}, [])
const openFile = useCallback(() => {
props.actions.openFile()
}, [])
const formatJson = useCallback(() => {
if (props.payload) {
try {
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
updatePayload(str)
} catch (error) {
props.globalActions.showError(`Format error: ${(error as Error)?.message}`)
const formatJson = useCallback(() => {
if (props.payload) {
try {
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
updatePayload(str)
} catch (error) {
props.globalActions.showError(`Format error: ${(error as Error)?.message}`)
}
}
}
}, [props.payload])
}, [props.payload])
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px', textAlign: 'center' }}>
<EditorModeSelect value={props.editorMode} onChange={updateMode} focusEditor={props.focusEditor} />
<FormatJsonButton editorMode={props.editorMode} focusEditor={props.focusEditor} formatJson={formatJson} />
<OpenFileButton editorMode={props.editorMode} openFile={openFile} />
<div style={{ float: 'right' }}>
<PublishButton publish={props.publish} focusEditor={props.focusEditor} />
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px', textAlign: 'center' }}>
<EditorModeSelect value={props.editorMode} onChange={updateMode} focusEditor={props.focusEditor} />
<FormatJsonButton editorMode={props.editorMode} focusEditor={props.focusEditor} formatJson={formatJson} />
<OpenFileButton editorMode={props.editorMode} openFile={openFile} />
<div style={{ float: 'right' }}>
<PublishButton publish={props.publish} focusEditor={props.focusEditor} />
</div>
</div>
</div>
</div>
)
})
const FormatJsonButton = React.memo(function FormatJsonButton(props: {
editorMode: string
focusEditor: () => void
formatJson: () => void
}) {
if (props.editorMode !== 'json') {
return null
)
}
)
return (
<Tooltip title="Format JSON">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.formatJson}
onFocus={props.focusEditor}
id="sidebar-publish-format-json"
>
<FormatAlignLeft style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
})
const FormatJsonButton = React.memo(
(props: { editorMode: string; focusEditor: () => void; formatJson: () => void }) => {
if (props.editorMode !== 'json') {
return null
}
const OpenFileButton = React.memo(function OpenFileButton(props: { editorMode: string; openFile: () => void }) {
return (
<Tooltip title="Open file">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.openFile}
id="sidebar-publish-open-file"
>
<AttachFileOutlined style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
})
return (
<Tooltip title="Format JSON">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.formatJson}
onFocus={props.focusEditor}
id="sidebar-publish-format-json"
>
<FormatAlignLeft style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
}
)
const PublishButton = memo(function PublishButton(props: { publish: () => void; focusEditor: () => void }) {
const OpenFileButton = React.memo((props: { editorMode: string; openFile: () => void }) => (
<Tooltip title="Open file">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.openFile}
id="sidebar-publish-open-file"
>
<AttachFileOutlined style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
))
const PublishButton = memo((props: { publish: () => void; focusEditor: () => void }) => {
const handleClickPublish = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -204,20 +202,16 @@ const PublishButton = memo(function PublishButton(props: { publish: () => void;
)
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
topic: state.publish.manualTopic,
payload: state.publish.payload,
editorMode: state.publish.editorMode,
retain: state.publish.retain,
}
}
const mapStateToProps = (state: AppState) => ({
topic: state.publish.manualTopic,
payload: state.publish.payload,
editorMode: state.publish.editorMode,
retain: state.publish.retain,
})
export default connect(mapStateToProps, mapDispatchToProps)(Publish)

View File

@@ -1,9 +1,10 @@
import History from '../HistoryDrawer'
import Message from './Model/Message'
import React, { useCallback, useMemo } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import Message from './Model/Message'
import History from '../HistoryDrawer'
import { publishActions } from '../../../actions'
const sha1 = require('sha1')
function PublishHistory(props: { history: Array<Message>; actions: typeof publishActions }) {
@@ -24,14 +25,12 @@ function PublishHistory(props: { history: Array<Message>; actions: typeof publis
value: message.payload || '',
}))
return <History autoOpen={true} items={items} onClick={didSelectHistoryEntry} />
return <History autoOpen items={items} onClick={didSelectHistoryEntry} />
}, [props.history])
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
export default connect(undefined, mapDispatchToProps)(PublishHistory)

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import { connect } from 'react-redux'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
import { AppState } from '../../../reducers'
import { publishActions } from '../../../actions'
import { QosSelect } from '../../QosSelect'
import { QoS } from '../../../../../backend/src/DataSource/MqttSource'
interface Props {
qos: QoS
@@ -17,18 +17,14 @@ function QosPublishOption(props: Props) {
return <QosSelect onChange={props.actions.publish.setQoS} selected={props.qos} />
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
publish: bindActionCreators(publishActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
publish: bindActionCreators(publishActions, dispatch),
},
})
const mapStateToProps = (state: AppState) => {
return {
qos: state.publish.qos,
}
}
const mapStateToProps = (state: AppState) => ({
qos: state.publish.qos,
})
export default connect(mapStateToProps, mapDispatchToProps)(QosPublishOption)

View File

@@ -1,10 +1,10 @@
import QosSelect from './QosPublishOption'
import React from 'react'
import { Checkbox, FormControlLabel, Tooltip } from '@mui/material'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
import { publishActions } from '../../../actions'
import { AppState } from '../../../reducers'
import QosSelect from './QosPublishOption'
export function RetainSwitch(props: { retain: boolean; actions: typeof publishActions }) {
const labelStyle = { margin: '0 8px 0 8px' }
@@ -29,16 +29,12 @@ export function RetainSwitch(props: { retain: boolean; actions: typeof publishAc
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
retain: state.publish.retain,
}
}
const mapStateToProps = (state: AppState) => ({
retain: state.publish.retain,
})
export default connect(mapStateToProps, mapDispatchToProps)(RetainSwitch)

View File

@@ -1,10 +1,10 @@
import ClearAdornment from '../../helper/ClearAdornment'
import React, { useCallback, useMemo, useRef } from 'react'
import { FormControl, Input, InputLabel } from '@mui/material'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
import { publishActions } from '../../../actions'
import { AppState } from '../../../reducers'
import ClearAdornment from '../../helper/ClearAdornment'
function TopicInput(props: { actions: typeof publishActions; manualTopic?: string; selectedTopic?: string }) {
const inputElement = useRef<HTMLInputElement>(null)
@@ -49,17 +49,13 @@ function TopicInput(props: { actions: typeof publishActions; manualTopic?: strin
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
manualTopic: state.publish.manualTopic,
selectedTopic: state.tree.get('selectedTopic')?.path(),
}
}
const mapStateToProps = (state: AppState) => ({
manualTopic: state.publish.manualTopic,
selectedTopic: state.tree.get('selectedTopic')?.path(),
})
export default connect(mapStateToProps, mapDispatchToProps)(TopicInput)

View File

@@ -34,7 +34,7 @@ function PublishTab(props: Props) {
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(2),
},
header: {
@@ -44,7 +44,7 @@ const styles = (theme: Theme) => ({
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
marginBottom: theme.spacing(0.5),
},

View File

@@ -1,14 +1,14 @@
import * as q from '../../../../backend/src/Model'
import React, { useState, useEffect, useCallback } from 'react'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { TopicViewModel } from '../../model/TopicViewModel'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import { AppState } from '../../reducers'
import DetailsTab from './DetailsTab'
import PublishTab from './PublishTab'
@@ -63,7 +63,7 @@ function SidebarNew(props: Props) {
return (
<div id="Sidebar" className={classes.root}>
<Box className={classes.mobileContent}>
<DetailsTab node={node} />
<DetailsTab node={node} connectionId={props.connectionId} />
</Box>
</div>
)
@@ -88,7 +88,7 @@ function SidebarNew(props: Props) {
<Box className={classes.tabContent}>
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
<DetailsTab node={node} />
<DetailsTab node={node} connectionId={props.connectionId} />
</Box>
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
<PublishTab connectionId={props.connectionId} />
@@ -106,18 +106,16 @@ const mapStateToProps = (state: AppState) => {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
})
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
height: '100%',
width: '100%',
},
@@ -132,19 +130,19 @@ const styles = (theme: Theme) => ({
minHeight: '48px',
fontSize: '14px',
fontWeight: 500,
textTransform: 'none' as 'none',
textTransform: 'none' as const,
padding: theme.spacing(1.5, 2),
},
tabContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
padding: theme.spacing(2),
},
mobileContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
padding: theme.spacing(2),
},
})

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