signalk-mqtt-export
Version:
SignalK plugin to selectively export data to MQTT with webapp management interface
857 lines (789 loc) โข 30.7 kB
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()">×</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>