signalk-parquet
Version:
SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.
981 lines (836 loc) • 32.2 kB
JavaScript
import { getPluginPath } from './utils.js';
let showCommandPaths = false;
// Path Configuration Management Functions
export async function loadPathConfigurations() {
try {
const response = await fetch(`${getPluginPath()}/api/config/paths`);
const result = await response.json();
if (result.success) {
displayPathConfigurations(result.paths);
} else {
document.getElementById('pathConfigContainer').innerHTML =
`<div class="error">Error loading path configurations: ${result.error}</div>`;
}
} catch (error) {
document.getElementById('pathConfigContainer').innerHTML =
`<div class="error">Network error: ${error.message}</div>`;
}
}
function displayPathConfigurations(paths) {
const container = document.getElementById('pathConfigContainer');
if (!paths || paths.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 40px; background: #f8f9fa; border-radius: 5px; margin: 20px 0;">
<h3 style="color: #666; margin-bottom: 10px;">No Path Configurations Found</h3>
<p style="color: #666; margin-bottom: 20px;">You need to configure SignalK paths to start collecting data.</p>
<button onclick="showAddPathForm()">➕ Add Your First Path</button>
</div>
`;
return;
}
// Filter paths based on showCommandPaths setting
const filteredPaths = showCommandPaths
? paths
: paths.filter(path => !path.path || !path.path.startsWith('commands.'));
if (filteredPaths.length === 0) {
const commandCount = paths.filter(
path => path.path && path.path.startsWith('commands.')
).length;
container.innerHTML = `
<div style="text-align: center; padding: 40px; background: #f8f9fa; border-radius: 5px; margin: 20px 0;">
<h3 style="color: #666; margin-bottom: 10px;">No Data Paths Found</h3>
<p style="color: #666; margin-bottom: 20px;">
${commandCount > 0 ? `${commandCount} command path${commandCount > 1 ? 's' : ''} hidden. ` : ''}
Add data paths to start collecting SignalK data.
</p>
<button onclick="showAddPathForm()">➕ Add Your First Data Path</button>
</div>
`;
return;
}
let html = '<div class="table-container"><table><thead><tr>';
html +=
'<th>Path</th><th>Always Enabled</th><th>Regimen</th><th>Source</th><th>Context</th><th>Exclude MMSI</th><th>Actions</th>';
html += '</tr></thead><tbody>';
filteredPaths.forEach((path, _filteredIndex) => {
// Find the original index in the full paths array
const originalIndex = paths.findIndex(p => p === path);
const excludeMMSI =
path.excludeMMSI && path.excludeMMSI.length > 0
? path.excludeMMSI.join(', ')
: '';
const isCommand = path.path && path.path.startsWith('commands.');
const rowClass = isCommand ? 'style="background-color: #fff3cd;"' : '';
html += `<tr data-index="${originalIndex}" ${rowClass}>
<td><code>${path.path || ''}</code>${isCommand ? ' <span style="color: #856404; font-size: 11px;">(Command)</span>' : ''}</td>
<td>${path.enabled ? '✅' : '❌'}</td>
<td>${path.regimen || ''}</td>
<td><code>${path.source || ''}</code></td>
<td>${path.context || 'vessels.self'}</td>
<td>${excludeMMSI}</td>
<td>
<button onclick="editPathConfiguration(${originalIndex})" style="padding: 5px 10px; font-size: 12px;">✏️ Edit</button>
<button onclick="removePathConfiguration(${originalIndex})" style="padding: 5px 10px; font-size: 12px; background: #dc3545;">🗑️ Remove</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
// Add summary info
const totalPaths = paths.length;
const commandPaths = paths.filter(
path => path.path && path.path.startsWith('commands.')
).length;
const dataPaths = totalPaths - commandPaths;
const summaryHtml = `
<div style="margin-top: 10px; padding: 10px; background: #f8f9fa; border-radius: 5px; font-size: 14px; color: #666;">
Showing ${filteredPaths.length} of ${totalPaths} paths
(${dataPaths} data path${dataPaths !== 1 ? 's' : ''}, ${commandPaths} command path${commandPaths !== 1 ? 's' : ''})
</div>
`;
container.innerHTML = html + summaryHtml;
}
export function toggleCommandPaths() {
showCommandPaths = !showCommandPaths;
const button = document.getElementById('toggleCommandsBtn');
button.textContent = showCommandPaths
? '🙈 Hide Commands'
: '👁️ Show Commands';
// Re-display the paths with the new filter
loadPathConfigurations();
}
export async function showAddPathForm() {
document.getElementById('addPathForm').style.display = 'block';
populateSignalKPaths();
await populateAvailableRegimens();
}
export function hideAddPathForm() {
document.getElementById('addPathForm').style.display = 'none';
clearAddPathForm();
}
function clearAddPathForm() {
document.getElementById('pathSignalK').value = '';
document.getElementById('pathSignalKCustom').value = '';
document.getElementById('pathSignalKCustom').style.display = 'none';
document.getElementById('pathEnabled').checked = false;
document.getElementById('pathSource').value = '';
document.getElementById('pathContext').value = 'vessels.self';
document.getElementById('pathExcludeMMSI').value = '';
document.getElementById('customRegimen').value = '';
// Clear regimen checkboxes
const checkboxes = document.querySelectorAll(
'#regimenCheckboxes input[type="checkbox"]'
);
checkboxes.forEach(checkbox => (checkbox.checked = false));
}
// Store the current tree for search functionality
let currentPathTree = null;
// Populate SignalK paths tree
async function populateSignalKPaths() {
const treeContainer = document.getElementById('pathSignalKTree');
const searchInput = document.getElementById('pathSignalKSearch');
const filterType =
document.querySelector('input[name="pathFilter"]:checked')?.value || 'self';
if (!treeContainer) return;
treeContainer.innerHTML =
'<div style="padding: 20px; text-align: center; color: #666;">Loading paths...</div>';
try {
const response = await fetch('/signalk/v1/api/');
const data = await response.json();
// Extract paths from SignalK API with filter
const allPaths = extractPathsFromSignalK(data, filterType);
// Get defined commands to exclude
const commandsResponse = await fetch(`${getPluginPath()}/api/commands`);
const commandsData = await commandsResponse.json();
const definedCommands = new Set();
if (commandsData.success && commandsData.commands) {
commandsData.commands.forEach(cmd => {
definedCommands.add(`commands.${cmd.command}`);
});
}
// Filter out defined command paths
const availablePaths = allPaths.filter(path => !definedCommands.has(path));
// Build and render tree
currentPathTree = buildPathTree(availablePaths);
const currentSearch = searchInput?.value || '';
renderPathTree(currentPathTree, treeContainer, currentSearch);
// Setup search listener (only once)
if (searchInput && !searchInput.dataset.listenerAttached) {
searchInput.dataset.listenerAttached = 'true';
searchInput.addEventListener('input', e => {
if (currentPathTree) {
renderPathTree(currentPathTree, treeContainer, e.target.value);
}
});
}
} catch (error) {
console.log('Could not load real-time SignalK paths:', error);
treeContainer.innerHTML =
'<div style="padding: 20px; text-align: center; color: #999;">Error loading paths</div>';
}
}
// Render the path tree with optional search filter
function renderPathTree(tree, container, searchTerm = '') {
let html = '';
Object.keys(tree)
.sort()
.forEach(key => {
html += renderTreeNode(key, tree[key], 0, searchTerm);
});
container.innerHTML =
html ||
'<div style="padding: 20px; text-align: center; color: #666;">No matches found</div>';
// Auto-expand all when searching
if (searchTerm) {
container.querySelectorAll('.path-tree-children').forEach(el => {
el.classList.add('expanded');
});
container.querySelectorAll('.path-tree-toggle').forEach(el => {
if (el.textContent) el.textContent = '▼';
});
}
}
/**
* Build a hierarchical tree structure from flat path list
*/
function buildPathTree(paths) {
const tree = {};
paths.forEach(path => {
const parts = path.split('.');
let current = tree;
parts.forEach((part, index) => {
if (!current[part]) {
current[part] = {
_children: {},
_hasValue: index === parts.length - 1,
_fullPath: parts.slice(0, index + 1).join('.'),
};
} else if (index === parts.length - 1) {
current[part]._hasValue = true;
}
current = current[part]._children;
});
});
return tree;
}
/**
* Check if node or any descendant matches search term
*/
function nodeMatchesSearch(node, searchTerm) {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
// Check if this node's full path contains the search term
if (node._fullPath && node._fullPath.toLowerCase().includes(term)) {
return true;
}
// Check if any children match
for (const childKey in node._children) {
if (nodeMatchesSearch(node._children[childKey], searchTerm)) {
return true;
}
}
return false;
}
/**
* Render a tree node
*/
function renderTreeNode(key, node, level = 0, searchTerm = '') {
const hasChildren = Object.keys(node._children).length > 0;
const fullPath = node._fullPath;
// Filter based on search - show node if it or any descendant matches
if (searchTerm && !nodeMatchesSearch(node, searchTerm)) {
return '';
}
const itemClasses = ['path-tree-item'];
if (node._hasValue) itemClasses.push('has-value');
let html = `<div class="path-tree-node" data-level="${level}">`;
html += `<div class="${itemClasses.join(' ')}" data-path="${fullPath}" onclick="selectPathTreeItem(this, '${fullPath}', ${node._hasValue})">`;
if (hasChildren) {
html += `<span class="path-tree-toggle" onclick="event.stopPropagation(); toggleTreeNode(this)">▶</span>`;
} else {
html += `<span class="path-tree-toggle"></span>`;
}
html += `<span class="path-tree-label">${key}</span>`;
html += `</div>`;
if (hasChildren) {
html += `<div class="path-tree-children">`;
Object.keys(node._children)
.sort()
.forEach(childKey => {
html += renderTreeNode(
childKey,
node._children[childKey],
level + 1,
searchTerm
);
});
html += `</div>`;
}
html += `</div>`;
return html;
}
// Update path filter
export function updatePathFilter() {
const filterType =
document.querySelector('input[name="pathFilter"]:checked')?.value || 'self';
const contextInput = document.getElementById('pathContext');
// Update context based on filter type
if (contextInput) {
if (filterType === 'self') {
contextInput.value = 'vessels.self';
} else {
contextInput.value = 'vessels.*';
}
}
// Populate paths - search will be automatically preserved and re-applied
populateSignalKPaths();
}
// Extract distinct paths from SignalK data, separating self vs non-self
function extractPathsFromSignalK(obj, filterType = 'self') {
const selfPaths = new Set();
const nonSelfPaths = new Set();
function extractRecursive(obj, prefix = '') {
if (!obj || typeof obj !== 'object') return;
for (const key in obj) {
if (
key === 'meta' ||
key === 'timestamp' ||
key === 'source' ||
key === '$source' ||
key === 'values' ||
key === 'sentence'
)
continue;
const currentPath = prefix ? `${prefix}.${key}` : key;
if (obj[key] && typeof obj[key] === 'object') {
if (obj[key].value !== undefined) {
// This is a data path with a value
selfPaths.add(currentPath);
}
// Always recurse to find nested paths
extractRecursive(obj[key], currentPath);
}
}
}
// Get the self vessel ID to distinguish self from other vessels
const selfVesselId = obj.self;
// Remove 'vessels.' prefix if present to get the actual vessel ID
const actualSelfId =
selfVesselId && selfVesselId.startsWith('vessels.')
? selfVesselId.replace('vessels.', '')
: selfVesselId;
// Process vessels if they exist
if (obj.vessels) {
// Process self vessel
if (actualSelfId && obj.vessels[actualSelfId]) {
extractRecursive(obj.vessels[actualSelfId], '');
}
// Process other vessels and extract generic paths
for (const vesselId in obj.vessels) {
if (vesselId !== actualSelfId) {
const tempPaths = new Set();
const extractOtherVessel = (obj, prefix = '') => {
if (!obj || typeof obj !== 'object') return;
for (const key in obj) {
if (
key === 'meta' ||
key === 'timestamp' ||
key === 'source' ||
key === '$source' ||
key === 'values' ||
key === 'sentence'
)
continue;
const currentPath = prefix ? `${prefix}.${key}` : key;
if (obj[key] && typeof obj[key] === 'object') {
if (obj[key].value !== undefined) {
tempPaths.add(currentPath);
}
// Always recurse to find nested paths
extractOtherVessel(obj[key], currentPath);
}
}
};
extractOtherVessel(obj.vessels[vesselId], '');
tempPaths.forEach(path => nonSelfPaths.add(path));
}
}
}
// Process top-level paths (non-vessel specific data like environment)
for (const key in obj) {
if (
key !== 'vessels' &&
key !== 'self' &&
key !== 'version' &&
key !== 'sources' &&
key !== 'meta' &&
key !== 'timestamp'
) {
extractRecursive(obj[key], key);
}
}
// Return the appropriate set based on filter
const targetPaths = filterType === 'self' ? selfPaths : nonSelfPaths;
return Array.from(targetPaths).sort();
}
// Populate available regimens
async function populateAvailableRegimens() {
const container = document.getElementById('regimenCheckboxes');
try {
// Get defined commands to use as regimens
const commandsResponse = await fetch(`${getPluginPath()}/api/commands`);
const commandsData = await commandsResponse.json();
const availableRegimens = [];
if (commandsData.success && commandsData.commands) {
commandsData.commands.forEach(cmd => {
availableRegimens.push(cmd.command);
});
}
container.innerHTML = '';
availableRegimens.forEach(regimen => {
const label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.marginBottom = '5px';
label.style.cursor = 'pointer';
label.style.fontSize = '0.9em';
const span = document.createElement('span');
span.textContent = regimen;
span.style.flex = '1';
span.style.marginRight = '8px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `regimen_${regimen}`;
checkbox.value = regimen;
checkbox.style.width = '16px';
checkbox.style.height = '16px';
label.appendChild(span);
label.appendChild(checkbox);
container.appendChild(label);
});
} catch (error) {
console.log('Could not load regimens:', error);
}
}
let listenersInitialized = false;
export function initPathConfigListeners() {
if (listenersInitialized) {
return;
}
document.addEventListener('change', function (e) {
if (e.target.id === 'pathSignalK') {
const customInput = document.getElementById('pathSignalKCustom');
if (e.target.value === 'custom') {
customInput.style.display = 'block';
customInput.focus();
} else {
customInput.style.display = 'none';
}
}
});
listenersInitialized = true;
}
// Add custom regimen
export function addCustomRegimen() {
const customInput = document.getElementById('customRegimen');
const regimenName = customInput.value.trim();
if (!regimenName) {
alert('Please enter a regimen name');
return;
}
// Check if already exists
if (document.getElementById(`regimen_${regimenName}`)) {
alert('This regimen already exists');
return;
}
const container = document.getElementById('regimenCheckboxes');
const div = document.createElement('div');
div.style.marginBottom = '5px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `regimen_${regimenName}`;
checkbox.value = regimenName;
checkbox.checked = true; // Auto-select custom regimens
checkbox.style.marginRight = '8px';
const label = document.createElement('label');
label.htmlFor = `regimen_${regimenName}`;
label.textContent = `${regimenName} (custom)`;
label.style.fontSize = '0.9em';
label.style.fontStyle = 'italic';
div.appendChild(checkbox);
div.appendChild(label);
container.appendChild(div);
customInput.value = '';
}
export async function addPathConfiguration() {
const excludeMMSIInput = document
.getElementById('pathExcludeMMSI')
.value.trim();
const excludeMMSI = excludeMMSIInput
? excludeMMSIInput
.split(',')
.map(mmsi => mmsi.trim())
.filter(mmsi => mmsi)
: [];
// Get path value (either from dropdown or custom input)
const pathDropdown = document.getElementById('pathSignalK');
const pathCustom = document.getElementById('pathSignalKCustom');
const selectedPath =
pathDropdown.value === 'custom'
? pathCustom.value.trim()
: pathDropdown.value.trim();
// Get selected regimens
const selectedRegimens = [];
const checkboxes = document.querySelectorAll(
'#regimenCheckboxes input[type="checkbox"]:checked'
);
checkboxes.forEach(checkbox => {
selectedRegimens.push(checkbox.value);
});
const pathConfig = {
path: selectedPath,
enabled: document.getElementById('pathEnabled').checked,
regimen: selectedRegimens.join(','), // Join multiple regimens with comma
source: document.getElementById('pathSource').value.trim() || undefined,
context:
document.getElementById('pathContext').value.trim() || 'vessels.self',
excludeMMSI: excludeMMSI.length > 0 ? excludeMMSI : undefined,
};
if (!pathConfig.path) {
alert('SignalK path is required');
return;
}
try {
const response = await fetch(`${getPluginPath()}/api/config/paths`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(pathConfig),
});
const result = await response.json();
if (result.success) {
hideAddPathForm();
await loadPathConfigurations();
alert('Path configuration added successfully');
} else {
alert(`Error adding path configuration: ${result.error}`);
}
} catch (error) {
alert(`Network error: ${error.message}`);
}
}
export async function removePathConfiguration(index) {
if (!confirm('Are you sure you want to remove this path configuration?')) {
return;
}
try {
const response = await fetch(
`${getPluginPath()}/api/config/paths/${index}`,
{
method: 'DELETE',
}
);
const result = await response.json();
if (result.success) {
await loadPathConfigurations();
alert('Path configuration removed successfully');
} else {
alert(`Error removing path configuration: ${result.error}`);
}
} catch (error) {
alert(`Network error: ${error.message}`);
}
}
let editingIndex = -1;
export async function editPathConfiguration(index) {
// Cancel any existing edit
if (editingIndex !== -1) {
cancelEdit();
}
editingIndex = index;
// Get the current path configuration
const response = await fetch(`${getPluginPath()}/api/config/paths`);
const result = await response.json();
if (!result.success || !result.paths[index]) {
alert('Error loading path configuration');
return;
}
const path = result.paths[index];
// Replace the row with edit form
const row = document.querySelector(`tr[data-index="${index}"]`);
if (row) {
const excludeMMSIValue =
path.excludeMMSI && path.excludeMMSI.length > 0
? path.excludeMMSI.join(', ')
: '';
const currentRegimens = path.regimen
? path.regimen.split(',').map(r => r.trim())
: [];
row.innerHTML = `
<td>
<div style="margin-bottom: 4px; font-size: 0.8em; display: flex; align-items: center; gap: 10px;">
<label style="display: flex; align-items: center; font-weight: normal; cursor: pointer; white-space: nowrap;">
<span style="margin-right: 3px;">🏠 Self</span>
<input type="radio" id="editPathFilterSelf${index}" name="editPathFilter${index}" value="self" checked onchange="updateEditPathFilter(${index})">
</label>
<label style="display: flex; align-items: center; font-weight: normal; cursor: pointer; white-space: nowrap;">
<span style="margin-right: 3px;">🚢 Others</span>
<input type="radio" id="editPathFilterOthers${index}" name="editPathFilter${index}" value="others" onchange="updateEditPathFilter(${index})">
</label>
</div>
<select id="editPath${index}" style="width: 100%; padding: 4px;">
<option value="">-- Select SignalK Path --</option>
<option value="custom">🖊️ Enter Custom Path</option>
</select>
<input type="text" id="editPathCustom${index}" placeholder="Enter custom SignalK path" style="width: 100%; padding: 4px; margin-top: 2px; display: none;">
</td>
<td><input type="checkbox" id="editEnabled${index}" ${path.enabled ? 'checked' : ''}></td>
<td>
<div style="max-height: 120px; overflow-y: auto; border: 1px solid #ddd; padding: 5px; background: white;">
<div id="editRegimenCheckboxes${index}">
<!-- Regimen checkboxes will be populated here -->
</div>
</div>
</td>
<td><input type="text" id="editSource${index}" value="${path.source || ''}" style="width: 100%;" placeholder="e.g., mqtt-weatherflow-udp"></td>
<td><input type="text" id="editContext${index}" value="${path.context || 'vessels.self'}" style="width: 100%;"></td>
<td><input type="text" id="editExcludeMMSI${index}" value="${excludeMMSIValue}" style="width: 100%;" placeholder="123456789, 987654321"></td>
<td>
<button onclick="saveEdit(${index})" style="padding: 5px 10px; font-size: 12px; background: #28a745;">💾 Save</button>
<button onclick="cancelEdit()" style="padding: 5px 10px; font-size: 12px; background: #6c757d;">❌ Cancel</button>
</td>
`;
// Populate the edit form with enhanced dropdowns
populateEditSignalKPaths(index, path.path);
await populateEditRegimens(index, currentRegimens);
}
}
// Populate SignalK paths dropdown for edit form
async function populateEditSignalKPaths(index, currentPath) {
const dropdown = document.getElementById(`editPath${index}`);
const customInput = document.getElementById(`editPathCustom${index}`);
const filterType =
document.querySelector(`input[name="editPathFilter${index}"]:checked`)
?.value || 'self';
try {
const response = await fetch('/signalk/v1/api/');
const data = await response.json();
const allPaths = extractPathsFromSignalK(data, filterType);
// Get defined commands to exclude
const commandsResponse = await fetch(`${getPluginPath()}/api/commands`);
const commandsData = await commandsResponse.json();
const definedCommands = new Set();
if (commandsData.success && commandsData.commands) {
commandsData.commands.forEach(cmd => {
definedCommands.add(`commands.${cmd.command}`);
});
}
// Filter out defined command paths
const availablePaths = allPaths.filter(path => !definedCommands.has(path));
// Clear existing options (except default ones)
while (dropdown.children.length > 2) {
dropdown.removeChild(dropdown.lastChild);
}
// Add available paths
availablePaths.forEach(path => {
const option = document.createElement('option');
option.value = path;
option.textContent = path;
dropdown.appendChild(option);
});
// Set current value and determine appropriate filter
if (currentPath) {
// Determine if current path is self or other vessel path
const isSelfPath =
!currentPath.includes('vessels.') ||
currentPath.startsWith('vessels.self.');
const targetFilter = isSelfPath ? 'self' : 'others';
// Set appropriate radio button
const selfRadio = document.getElementById(`editPathFilterSelf${index}`);
const othersRadio = document.getElementById(
`editPathFilterOthers${index}`
);
if (targetFilter === 'self') {
selfRadio.checked = true;
} else {
othersRadio.checked = true;
}
// Clean path for comparison (remove vessels.self. prefix if present)
const cleanCurrentPath = currentPath.replace('vessels.self.', '');
// Refresh paths with correct filter if needed
if (targetFilter !== filterType) {
updateEditPathFilter(index);
return;
}
if (availablePaths.includes(cleanCurrentPath)) {
dropdown.value = cleanCurrentPath;
} else if (availablePaths.includes(currentPath)) {
dropdown.value = currentPath;
} else {
dropdown.value = 'custom';
customInput.style.display = 'block';
customInput.value = currentPath;
}
}
// Add change listener
dropdown.addEventListener('change', function () {
if (this.value === 'custom') {
customInput.style.display = 'block';
customInput.focus();
} else {
customInput.style.display = 'none';
}
});
} catch (error) {
console.log('Could not load real-time SignalK paths for edit:', error);
// Fallback to custom input
dropdown.value = 'custom';
customInput.style.display = 'block';
customInput.value = currentPath || '';
}
}
// Update path filter for edit form
export function updateEditPathFilter(index) {
const dropdown = document.getElementById(`editPath${index}`);
const currentValue = dropdown.value;
// Get current path from either dropdown or custom input
const customInput = document.getElementById(`editPathCustom${index}`);
const currentPath =
currentValue === 'custom' ? customInput.value : currentValue;
populateEditSignalKPaths(index, currentPath);
}
// Populate regimens for edit form
async function populateEditRegimens(index, currentRegimens) {
const container = document.getElementById(`editRegimenCheckboxes${index}`);
try {
// Get defined commands to use as regimens
const commandsResponse = await fetch(`${getPluginPath()}/api/commands`);
const commandsData = await commandsResponse.json();
const availableRegimens = [];
if (commandsData.success && commandsData.commands) {
commandsData.commands.forEach(cmd => {
availableRegimens.push(cmd.command);
});
}
container.innerHTML = '';
// Add available regimens
availableRegimens.forEach(regimen => {
const label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.marginBottom = '3px';
label.style.cursor = 'pointer';
label.style.fontSize = '0.8em';
const span = document.createElement('span');
span.textContent = regimen;
span.style.flex = '1';
span.style.marginRight = '6px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `editRegimen_${index}_${regimen}`;
checkbox.value = regimen;
checkbox.checked = currentRegimens.includes(regimen);
checkbox.style.width = '14px';
checkbox.style.height = '14px';
label.appendChild(span);
label.appendChild(checkbox);
container.appendChild(label);
});
// Add custom regimens that aren't in the available list
currentRegimens.forEach(regimen => {
if (!availableRegimens.includes(regimen)) {
const label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.marginBottom = '3px';
label.style.cursor = 'pointer';
label.style.fontSize = '0.8em';
label.style.fontStyle = 'italic';
const span = document.createElement('span');
span.textContent = `${regimen} (custom)`;
span.style.flex = '1';
span.style.marginRight = '6px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `editRegimen_${index}_${regimen}`;
checkbox.value = regimen;
checkbox.checked = true;
checkbox.style.width = '14px';
checkbox.style.height = '14px';
label.appendChild(span);
label.appendChild(checkbox);
container.appendChild(label);
}
});
} catch (error) {
console.log('Could not load regimens for edit:', error);
}
}
export async function saveEdit(index) {
const excludeMMSIInput = document
.getElementById(`editExcludeMMSI${index}`)
.value.trim();
const excludeMMSI = excludeMMSIInput
? excludeMMSIInput
.split(',')
.map(mmsi => mmsi.trim())
.filter(mmsi => mmsi)
: [];
// Get path value (either from dropdown or custom input)
const pathDropdown = document.getElementById(`editPath${index}`);
const pathCustom = document.getElementById(`editPathCustom${index}`);
const selectedPath =
pathDropdown.value === 'custom'
? pathCustom.value.trim()
: pathDropdown.value.trim();
// Get selected regimens
const selectedRegimens = [];
const checkboxes = document.querySelectorAll(
`#editRegimenCheckboxes${index} input[type="checkbox"]:checked`
);
checkboxes.forEach(checkbox => {
selectedRegimens.push(checkbox.value);
});
const updatedPath = {
path: selectedPath,
enabled: document.getElementById(`editEnabled${index}`).checked,
regimen: selectedRegimens.join(','), // Join multiple regimens with comma
source:
document.getElementById(`editSource${index}`).value.trim() || undefined,
context:
document.getElementById(`editContext${index}`).value.trim() ||
'vessels.self',
excludeMMSI: excludeMMSI.length > 0 ? excludeMMSI : undefined,
};
if (!updatedPath.path) {
alert('SignalK path is required');
return;
}
try {
const response = await fetch(
`${getPluginPath()}/api/config/paths/${index}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedPath),
}
);
const result = await response.json();
if (result.success) {
editingIndex = -1;
await loadPathConfigurations();
alert('Path configuration updated successfully');
} else {
alert(`Error updating path configuration: ${result.error}`);
}
} catch (error) {
alert(`Network error: ${error.message}`);
}
}
export function cancelEdit() {
editingIndex = -1;
loadPathConfigurations();
}
// Command Management Functions