UNPKG

signalk-mqtt-import

Version:

SignalK plugin to selectively import data from MQTT with webapp management interface

637 lines (591 loc) โ€ข 23.3 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/jpeg" href="mqtt_import.png"> <title>Zennora MQTT Import Manager</title> <style> body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 1400px; margin: 0 auto; padding: 20px; background-color: #f5f5f5; } .header { background: linear-gradient(135deg, #e60909 0%, #35f705 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; text-align: center; } .header h1 { margin: 0; font-size: 2.5em; } .header p { margin: 10px 0 0 0; opacity: 0.9; } .card { background: white; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 20px; } .status-card { background: #f8f9fa; padding: 20px; border-radius: 8px; text-align: center; border: 1px solid #dee2e6; } .status-value { font-size: 2em; font-weight: bold; color: #2196F3; display: block; } .status-label { color: #6c757d; margin-top: 5px; } .status-connected { color: #28a745; } .status-disconnected { color: #dc3545; } .btn { background: #2196F3; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 14px; margin: 5px; transition: background-color 0.3s; } .btn:hover { background: #1976D2; } .btn:disabled { background: #ccc; cursor: not-allowed; } .btn-success { background: #28a745; } .btn-success:hover { background: #218838; } .btn-danger { background: #dc3545; } .btn-danger:hover { background: #c82333; } .btn-warning { background: #ffc107; color: #212529; } .btn-warning:hover { background: #e0a800; } .rules-table { width: 100%; border-collapse: collapse; margin-top: 20px; } .rules-table th, .rules-table td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; } .rules-table th { background-color: #f8f9fa; font-weight: 600; } .rules-table tr:hover { background-color: #f5f5f5; } .switch { position: relative; display: inline-block; width: 60px; height: 34px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } .slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #2196F3; } input:checked + .slider:before { transform: translateX(26px); } .form-group { margin-bottom: 15px; } .form-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; } .form-group input, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); } .modal-content { background-color: white; margin: 5% auto; padding: 30px; border-radius: 10px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; } .close { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; } .close:hover { color: black; } .logs { background: #1e1e1e; color: #f1f1f1; padding: 15px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 12px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; } .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; margin: 2px; } .tag-enabled { background-color: #d4edda; color: #155724; } .tag-disabled { background-color: #f8d7da; color: #721c24; } .tag-context { background-color: #d1ecf1; color: #0c5460; } .tag-source { background-color: #fff3cd; color: #856404; } .tag-topic { background-color: #e2e3e5; color: #41464b; } .info-box { background: #e3f2fd; border: 1px solid #2196F3; border-radius: 6px; padding: 15px; margin-bottom: 20px; } .info-box h4 { margin-top: 0; color: #1976D2; } </style> </head> <body> <div class="header"> <h1> <img src="mqtt_import.png" alt="Zennora" style="height: 80px; vertical-align: middle; margin-right: 10px; border-radius: 5px;"> SignalK MQTT Import Manager</h1> <p>Import SignalK data from MQTT topics with flexible rules</p> </div> <!-- Info Box --> <div class="info-box"> <h4>๐Ÿ“ฅ MQTT Import</h4> <p>This plugin subscribes to MQTT topics and imports the data into SignalK. It works as the inverse of the MQTT Export plugin, allowing you to selectively choose which MQTT data to import and how to map it to SignalK paths.</p> </div> <!-- Status Overview --> <div class="card"> <h3>๐Ÿ“Š Status Overview</h3> <div class="status-grid"> <div class="status-card"> <span id="mqttStatus" class="status-value status-disconnected">Disconnected</span> <div class="status-label">MQTT Connection</div> </div> <div class="status-card"> <span id="activeRules" class="status-value">0</span> <div class="status-label">Active Rules</div> </div> <div class="status-card"> <span id="messagesReceived" class="status-value">0</span> <div class="status-label">Messages Received</div> </div> <div class="status-card"> <span id="totalRules" class="status-value">0</span> <div class="status-label">Total Rules</div> </div> </div> <div> <button class="btn btn-success" onclick="refreshStatus()">๐Ÿ”„ Refresh Status</button> <button class="btn btn-warning" onclick="testMQTT()">๐Ÿงช Test MQTT</button> </div> </div> <!-- Import Rules Management --> <div class="card"> <h3>๐Ÿ“ฅ Import Rules</h3> <div> <button class="btn btn-success" onclick="showAddRuleModal()">โž• Add Rule</button> <button class="btn" onclick="refreshRules()">๐Ÿ”„ Refresh Rules</button> <button class="btn btn-warning" onclick="saveRules()">๐Ÿ’พ Save Changes</button> </div> <div id="rulesContainer"> <table class="rules-table"> <thead> <tr> <th>Enabled</th> <th>Name</th> <th>MQTT Topic</th> <th>SignalK Context</th> <th>SignalK Path</th> <th>Source Label</th> <th>Payload Format</th> <th>Ignore Duplicates</th> <th>Exclude MMSI</th> <th>Actions</th> </tr> </thead> <tbody id="rulesTableBody"> <!-- Rules will be populated here --> </tbody> </table> </div> </div> <!-- Activity Log --> <div class="card"> <h3>๐Ÿ“ Activity Log</h3> <div id="activityLog" class="logs"> Activity logs will appear here... </div> </div> <!-- Add/Edit Rule Modal --> <div id="ruleModal" class="modal"> <div class="modal-content"> <span class="close" onclick="closeRuleModal()">&times;</span> <h2 id="modalTitle">Add Import Rule</h2> <form id="ruleForm"> <div class="form-group"> <label for="ruleName">Rule Name:</label> <input type="text" id="ruleName" name="name" required> </div> <div class="form-group"> <label for="ruleMqttTopic">MQTT Topic:</label> <input type="text" id="ruleMqttTopic" name="mqttTopic" required placeholder="e.g., vessels/self/navigation/+, vessels/+/navigation/position"> <small>Use + for single-level wildcard, # for multi-level wildcard</small> </div> <div class="form-group"> <label for="ruleSignalKContext">SignalK Context (Optional):</label> <input type="text" id="ruleSignalKContext" name="signalKContext" placeholder="e.g., vessels.self (leave empty to extract from topic)"> </div> <div class="form-group"> <label for="ruleSignalKPath">SignalK Path (Optional):</label> <input type="text" id="ruleSignalKPath" name="signalKPath" placeholder="e.g., navigation.position (leave empty to extract from topic)"> </div> <div class="form-group"> <label for="ruleSourceLabel">Source Label:</label> <input type="text" id="ruleSourceLabel" name="sourceLabel" placeholder="e.g., mqtt-import, remote-sensor (leave blank for default)"> </div> <div class="form-group"> <label for="rulePayloadFormat">Payload Format:</label> <select id="rulePayloadFormat" name="payloadFormat"> <option value="full">Full SignalK Structure</option> <option value="value-only">Value Only</option> </select> </div> <div class="form-group"> <label> <input type="checkbox" id="ruleEnabled" name="enabled" checked> Rule Enabled </label> </div> <div class="form-group"> <label> <input type="checkbox" id="ruleIgnoreDuplicates" name="ignoreDuplicates" checked> Ignore Duplicate Messages </label> </div> <div class="form-group"> <label for="ruleExcludeMMSI">Exclude MMSI Numbers:</label> <input type="text" id="ruleExcludeMMSI" name="excludeMMSI" placeholder="e.g., 123456789, 987654321"> <small>Comma-separated list of MMSI numbers to exclude from this rule</small> </div> <div> <button type="submit" class="btn btn-success">Save Rule</button> <button type="button" class="btn" onclick="closeRuleModal()">Cancel</button> </div> </form> </div> </div> <script> let currentRules = []; let editingRuleIndex = -1; // Initialize the page document.addEventListener('DOMContentLoaded', function() { refreshStatus(); refreshRules(); // Auto-refresh every 10 seconds setInterval(refreshStatus, 10000); }); // Refresh status information async function refreshStatus() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/mqtt-status'); const data = await response.json(); if (data.success) { document.getElementById('mqttStatus').textContent = data.connected ? 'Connected' : 'Disconnected'; document.getElementById('mqttStatus').className = data.connected ? 'status-value status-connected' : 'status-value status-disconnected'; } // Get statistics const statsResponse = await fetch('/plugins/signalk-mqtt-import/api/stats'); const statsData = await statsResponse.json(); if (statsData.success) { document.getElementById('activeRules').textContent = statsData.stats.enabledRules; document.getElementById('messagesReceived').textContent = statsData.stats.messagesReceived; document.getElementById('totalRules').textContent = statsData.stats.totalRules; } addLog('Status refreshed successfully'); } catch (error) { addLog('Error refreshing status: ' + error.message); } } // Refresh import rules async function refreshRules() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/rules'); const data = await response.json(); if (data.success) { currentRules = data.rules; displayRules(); addLog('Rules refreshed successfully'); } } catch (error) { addLog('Error refreshing rules: ' + error.message); } } // Display rules in the table function displayRules() { const tbody = document.getElementById('rulesTableBody'); tbody.innerHTML = ''; currentRules.forEach((rule, index) => { const row = document.createElement('tr'); row.innerHTML = ` <td> <label class="switch"> <input type="checkbox" ${rule.enabled ? 'checked' : ''} onchange="toggleRule(${index})"> <span class="slider"></span> </label> </td> <td>${rule.name}</td> <td><span class="tag tag-topic">${rule.mqttTopic}</span></td> <td><span class="tag tag-context">${rule.signalKContext || 'Auto'}</span></td> <td><code>${rule.signalKPath || 'Auto'}</code></td> <td><span class="tag tag-source">${rule.sourceLabel}</span></td> <td><span class="tag">${rule.payloadFormat}</span></td> <td><span class="tag ${rule.ignoreDuplicates ? 'tag-enabled' : 'tag-disabled'}">${rule.ignoreDuplicates ? 'Yes' : 'No'}</span></td> <td><span class="tag tag-topic">${rule.excludeMMSI || 'None'}</span></td> <td> <button class="btn" onclick="editRule(${index})">โœ๏ธ Edit</button> <button class="btn btn-danger" onclick="deleteRule(${index})">๐Ÿ—‘๏ธ Delete</button> </td> `; tbody.appendChild(row); }); } // Toggle rule enabled/disabled function toggleRule(index) { currentRules[index].enabled = !currentRules[index].enabled; addLog(`Rule "${currentRules[index].name}" ${currentRules[index].enabled ? 'enabled' : 'disabled'}`); } // Show add rule modal function showAddRuleModal() { editingRuleIndex = -1; document.getElementById('modalTitle').textContent = 'Add Import Rule'; document.getElementById('ruleForm').reset(); document.getElementById('ruleEnabled').checked = true; document.getElementById('ruleIgnoreDuplicates').checked = true; document.getElementById('ruleSourceLabel').value = ''; document.getElementById('ruleModal').style.display = 'block'; } // Edit rule function editRule(index) { editingRuleIndex = index; const rule = currentRules[index]; document.getElementById('modalTitle').textContent = 'Edit Import Rule'; document.getElementById('ruleName').value = rule.name; document.getElementById('ruleMqttTopic').value = rule.mqttTopic; document.getElementById('ruleSignalKContext').value = rule.signalKContext || ''; document.getElementById('ruleSignalKPath').value = rule.signalKPath || ''; document.getElementById('ruleSourceLabel').value = rule.sourceLabel || ''; document.getElementById('rulePayloadFormat').value = rule.payloadFormat || 'full'; document.getElementById('ruleEnabled').checked = rule.enabled; document.getElementById('ruleIgnoreDuplicates').checked = rule.ignoreDuplicates !== false; document.getElementById('ruleExcludeMMSI').value = rule.excludeMMSI || ''; document.getElementById('ruleModal').style.display = 'block'; } // Delete rule function deleteRule(index) { if (confirm(`Are you sure you want to delete rule "${currentRules[index].name}"?`)) { currentRules.splice(index, 1); displayRules(); addLog('Rule deleted'); } } // Close rule modal function closeRuleModal() { document.getElementById('ruleModal').style.display = 'none'; } // Handle rule form submission document.getElementById('ruleForm').addEventListener('submit', function(e) { e.preventDefault(); const formData = new FormData(e.target); const rule = { id: editingRuleIndex >= 0 ? currentRules[editingRuleIndex].id : Date.now().toString(), name: formData.get('name'), mqttTopic: formData.get('mqttTopic'), signalKContext: formData.get('signalKContext'), signalKPath: formData.get('signalKPath'), sourceLabel: formData.get('sourceLabel'), payloadFormat: formData.get('payloadFormat'), enabled: document.getElementById('ruleEnabled').checked, ignoreDuplicates: document.getElementById('ruleIgnoreDuplicates').checked, excludeMMSI: formData.get('excludeMMSI') }; if (editingRuleIndex >= 0) { currentRules[editingRuleIndex] = rule; addLog(`Rule "${rule.name}" updated`); } else { currentRules.push(rule); addLog(`Rule "${rule.name}" added`); } displayRules(); closeRuleModal(); }); // Save rules to server async function saveRules() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules: currentRules }) }); const data = await response.json(); if (data.success) { addLog('Rules saved successfully and MQTT subscriptions updated'); refreshStatus(); } else { addLog('Error saving rules: ' + data.error); } } catch (error) { addLog('Error saving rules: ' + error.message); } } // Test MQTT connection async function testMQTT() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/test-mqtt', { method: 'POST' }); const data = await response.json(); if (data.success) { addLog('MQTT test successful: ' + data.message); } else { addLog('MQTT test failed: ' + data.error); } } catch (error) { addLog('MQTT test error: ' + error.message); } } // Add log entry function addLog(message) { const logContainer = document.getElementById('activityLog'); const timestamp = new Date().toLocaleTimeString(); const logEntry = `[${timestamp}] ${message}\n`; logContainer.textContent += logEntry; logContainer.scrollTop = logContainer.scrollHeight; } // Close modal when clicking outside window.onclick = function(event) { const modal = document.getElementById('ruleModal'); if (event.target === modal) { closeRuleModal(); } } </script> </body> </html>