UNPKG

signalk-mqtt-export

Version:

SignalK plugin to selectively export data to MQTT with webapp management interface

857 lines (789 loc) โ€ข 30.7 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_export.png"> <title>SignalK MQTT Export 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: 8px; padding: 8px 12px; margin-bottom: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .card h3 { margin-top: 0; margin-bottom: 8px; font-size: 1.1em; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; } .card h3:hover { opacity: 0.8; } .collapse-icon { font-size: 0.8em; transition: transform 0.3s; } .collapse-icon.collapsed { transform: rotate(-90deg); } .card-content { overflow: hidden; transition: max-height 0.3s ease-out, opacity 0.3s ease-out; max-height: 2000px; opacity: 1; } .card-content.collapsed { max-height: 0; opacity: 0; } .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-bottom: 8px; } .status-card { background: #f8f9fa; padding: 8px; border-radius: 6px; text-align: center; border: 1px solid #dee2e6; } .status-value { font-size: 1.5em; font-weight: bold; color: #2196F3; display: block; } .status-label { color: #6c757d; margin-top: 3px; font-size: 0.9em; } .status-connected { color: #28a745; } .status-disconnected { color: #dc3545; } .btn { background: #2196F3; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; margin: 3px; 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: 8px; } .rules-table th, .rules-table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid #ddd; font-size: 0.9em; } .rules-table th { background-color: #f8f9fa; font-weight: 600; } .rules-table tr:hover { background-color: #f5f5f5; } .switch { position: relative; display: inline-block; width: 44px; height: 24px; } .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: 24px; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #2196F3; } input:checked + .slider:before { transform: translateX(20px); } .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: 8px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 11px; height: 150px; overflow-y: auto; overflow-x: hidden; white-space: pre-wrap; } .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500; margin: 1px; } .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; } .info-box { display: none; position: fixed; top: 20px; right: 20px; min-width: 300px; max-width: 500px; border-radius: 6px; padding: 15px 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 2000; animation: slideIn 0.3s ease-out; } .info-box.show { display: block; } .info-box.info { background: #e3f2fd; border: 1px solid #2196F3; } .info-box.info h4 { color: #1976D2; } .info-box.success { background: #d4edda; border: 1px solid #28a745; } .info-box.success h4 { color: #155724; } .info-box.error { background: #f8d7da; border: 1px solid #dc3545; } .info-box.error h4 { color: #721c24; } .info-box h4 { margin: 0 0 8px 0; font-size: 16px; } .info-box p { margin: 0; font-size: 14px; } @keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } </style> </head> <body> <div class="header"> <h1> <img src="mqtt_export.png" alt="MQTT Export" style="height: 80px; vertical-align: middle; margin-right: 10px; border-radius: 5px;"> SignalK MQTT Export Manager</h1> <p>Export SignalK data to MQTT topics with flexible rules</p> </div> <!-- Status Notification --> <div id="statusNotification" class="info-box"> <h4 id="notificationTitle"></h4> <p id="notificationMessage"></p> </div> <!-- Status Overview --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“Š Status Overview</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <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="activeSubscriptions" class="status-value">0</span> <div class="status-label">Active Subscriptions</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> </div> <!-- Export Rules Management --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“ค Export Rules</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <div> <button class="btn btn-success" onclick="showAddRuleModal()">โž• Add Rule</button> <button class="btn" onclick="refreshRules()">๐Ÿ”„ Refresh Rules</button> </div> <div id="rulesContainer"> <table class="rules-table"> <thead> <tr> <th>Enabled</th> <th>Name</th> <th>Context</th> <th>Path</th> <th>Source</th> <th>Exclude MMSIs</th> <th>Send on Change</th> <th>Period</th> <th>Actions</th> </tr> </thead> <tbody id="rulesTableBody"> <!-- Rules will be populated here --> </tbody> </table> </div> </div> </div> <!-- Activity Log --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“ Activity Log</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <div id="activityLog" class="logs"> Activity logs will appear here... </div> </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 Export 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="ruleContext">Context:</label> <select id="ruleContext" name="context" required> <option value="vessels.self">vessels.self</option> <option value="vessels.*">vessels.* (All vessels)</option> <option value="vessels.urn:*">vessels.urn:* (All AIS vessels)</option> <option value="meteo.*">meteo.* (All weather stations)</option> </select> </div> <div class="form-group"> <label for="rulePath">SignalK Path:</label> <input type="text" id="rulePath" name="path" required placeholder="e.g., navigation.position, *, electrical.batteries.*"> </div> <div class="form-group"> <label for="ruleSource">Source Filter (Optional):</label> <input type="text" id="ruleSource" name="source" placeholder="e.g., maiana.GP, NighthawkM5, leave empty for all"> </div> <div class="form-group"> <label for="ruleSourceLabel">Source Label for Export (Optional):</label> <input type="text" id="ruleSourceLabel" name="sourceLabel" placeholder="e.g., mqtt-export, remote-boat"> <small style="color: #6c757d;">If provided, exports with custom $source instead of original source</small> </div> <div class="form-group"> <label for="rulePeriod">Update Period (ms):</label> <input type="number" id="rulePeriod" name="period" value="1000" min="100" max="60000"> </div> <div class="form-group"> <label for="ruleQos">MQTT QoS:</label> <select id="ruleQos" name="qos"> <option value="0">0 - At most once</option> <option value="1">1 - At least once</option> <option value="2">2 - Exactly once</option> </select> </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 for="ruleTopicTemplate">Topic Template (Optional):</label> <input type="text" id="ruleTopicTemplate" name="topicTemplate" placeholder="e.g., marine/{context}/{path}"> </div> <div class="form-group"> <label for="ruleExcludeMMSI">Exclude MMSIs (Optional):</label> <input type="text" id="ruleExcludeMMSI" name="excludeMMSI" placeholder="e.g., 368396230, 123456789"> </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="ruleSendOnChange" name="sendOnChange" checked> Send on Change Only </label> </div> <div class="form-group"> <label> <input type="checkbox" id="ruleRetain" name="retain"> MQTT Retain </label> </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; let notificationTimeout = null; // Show status notification function showNotification(title, message, type = 'info', duration = 3000) { const notification = document.getElementById('statusNotification'); const titleEl = document.getElementById('notificationTitle'); const messageEl = document.getElementById('notificationMessage'); // Clear any existing timeout if (notificationTimeout) { clearTimeout(notificationTimeout); } // Remove all type classes notification.classList.remove('info', 'success', 'error'); // Set content and type titleEl.textContent = title; messageEl.textContent = message; notification.classList.add(type, 'show'); // Auto-hide after duration if (duration > 0) { notificationTimeout = setTimeout(() => { notification.classList.remove('show'); }, duration); } } // Toggle card collapse/expand function toggleCard(headerElement) { const icon = headerElement.querySelector('.collapse-icon'); const cardContent = headerElement.nextElementSibling; icon.classList.toggle('collapsed'); cardContent.classList.toggle('collapsed'); } // 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-export/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 rules status const rulesResponse = await fetch('/plugins/signalk-mqtt-export/api/rules'); const rulesData = await rulesResponse.json(); if (rulesData.success) { const activeCount = rulesData.rules.filter(r => r.enabled).length; document.getElementById('activeRules').textContent = activeCount; document.getElementById('activeSubscriptions').textContent = rulesData.activeSubscriptions; document.getElementById('totalRules').textContent = rulesData.rules.length; } addLog('Status refreshed successfully'); } catch (error) { addLog('Error refreshing status: ' + error.message); } } // Refresh export rules async function refreshRules() { try { const response = await fetch('/plugins/signalk-mqtt-export/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-context">${rule.context}</span></td> <td><code>${rule.path}</code></td> <td><span class="tag tag-source">${rule.source || 'All'}</span></td> <td><span class="tag tag-source">${rule.excludeMMSI || 'None'}</span></td> <td><span class="tag ${rule.sendOnChange ? 'tag-enabled' : 'tag-disabled'}">${rule.sendOnChange ? 'Yes' : 'No'}</span></td> <td>${rule.period}ms</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 async function toggleRule(index) { const rule = currentRules[index]; const newState = !rule.enabled; const ruleName = rule.name; // Update local state currentRules[index].enabled = newState; displayRules(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${ruleName}" ${newState ? 'enabled' : 'disabled'}`); showNotification('Success', `Rule "${ruleName}" ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); } catch (error) { // Revert on failure currentRules[index].enabled = !newState; displayRules(); addLog(`Failed to ${newState ? 'enable' : 'disable'} rule "${ruleName}": ${error.message}`); showNotification('Error', `Failed to ${newState ? 'enable' : 'disable'} rule`, 'error', 5000); } } // Show add rule modal function showAddRuleModal() { editingRuleIndex = -1; document.getElementById('modalTitle').textContent = 'Add Export Rule'; document.getElementById('ruleForm').reset(); document.getElementById('ruleEnabled').checked = true; document.getElementById('ruleModal').style.display = 'block'; } // Edit rule function editRule(index) { editingRuleIndex = index; const rule = currentRules[index]; document.getElementById('modalTitle').textContent = 'Edit Export Rule'; document.getElementById('ruleName').value = rule.name; document.getElementById('ruleContext').value = rule.context; document.getElementById('rulePath').value = rule.path; document.getElementById('ruleSource').value = rule.source || ''; document.getElementById('ruleSourceLabel').value = rule.sourceLabel || ''; document.getElementById('rulePeriod').value = rule.period; document.getElementById('ruleQos').value = rule.qos; document.getElementById('rulePayloadFormat').value = rule.payloadFormat; document.getElementById('ruleTopicTemplate').value = rule.topicTemplate || ''; document.getElementById('ruleExcludeMMSI').value = rule.excludeMMSI || ''; document.getElementById('ruleEnabled').checked = rule.enabled; document.getElementById('ruleSendOnChange').checked = rule.sendOnChange !== false; // Default to true document.getElementById('ruleRetain').checked = rule.retain; document.getElementById('ruleModal').style.display = 'block'; } // Delete rule async function deleteRule(index) { const ruleName = currentRules[index].name; if (!confirm(`Are you sure you want to delete rule "${ruleName}"?`)) { return; } // Store the deleted rule in case we need to restore it const deletedRule = currentRules[index]; // Remove from local state currentRules.splice(index, 1); displayRules(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${ruleName}" deleted`); showNotification('Success', `Rule "${ruleName}" deleted`, 'success', 2000); } catch (error) { // Restore on failure currentRules.splice(index, 0, deletedRule); displayRules(); addLog(`Failed to delete rule "${ruleName}": ${error.message}`); showNotification('Error', `Failed to delete rule "${ruleName}"`, 'error', 5000); } } // Close rule modal function closeRuleModal() { document.getElementById('ruleModal').style.display = 'none'; } // Handle rule form submission document.getElementById('ruleForm').addEventListener('submit', async 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'), context: formData.get('context'), path: formData.get('path'), source: formData.get('source'), sourceLabel: formData.get('sourceLabel'), period: parseInt(formData.get('period')), qos: parseInt(formData.get('qos')), payloadFormat: formData.get('payloadFormat'), topicTemplate: formData.get('topicTemplate'), excludeMMSI: formData.get('excludeMMSI'), enabled: document.getElementById('ruleEnabled').checked, sendOnChange: document.getElementById('ruleSendOnChange').checked, retain: document.getElementById('ruleRetain').checked }; const isEditing = editingRuleIndex >= 0; const actionName = isEditing ? 'updated' : 'added'; let oldRule = null; // Update local state if (isEditing) { oldRule = currentRules[editingRuleIndex]; currentRules[editingRuleIndex] = rule; } else { currentRules.push(rule); } displayRules(); closeRuleModal(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${rule.name}" ${actionName}`); showNotification('Success', `Rule "${rule.name}" ${actionName} successfully`, 'success', 2000); } catch (error) { // Revert on failure if (isEditing) { currentRules[editingRuleIndex] = oldRule; } else { currentRules.pop(); } displayRules(); addLog(`Failed to ${actionName.slice(0, -1)} rule "${rule.name}": ${error.message}`); showNotification('Error', `Failed to ${actionName.slice(0, -1)} rule "${rule.name}"`, 'error', 5000); } }); // Save rules to server (internal helper function) async function saveRulesToServer() { const response = await fetch('/plugins/signalk-mqtt-export/api/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules: currentRules }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to save rules'); } // Refresh status after successful save refreshStatus(); return data; } // Test MQTT connection async function testMQTT() { try { const response = await fetch('/plugins/signalk-mqtt-export/api/test-mqtt', { method: 'POST' }); const data = await response.json(); if (data.success) { showNotification('MQTT Test', data.message, 'success'); addLog('MQTT test successful: ' + data.message); } else { showNotification('MQTT Test Failed', data.error, 'error', 5000); addLog('MQTT test failed: ' + data.error); } } catch (error) { showNotification('MQTT Test Error', error.message, 'error', 5000); 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>