signalk-parquet
Version:
SignalK plugin to save marine data directly to Parquet files with regimen-based control
1,261 lines (1,071 loc) âĸ 54.5 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SignalK Parquet Data Interface</title>
<link rel="icon" type="image/png" href="parquet.png">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #2BB5D6 0%, #013542 100%);
color: white;
padding: 20px 0;
margin-bottom: 20px;
border-radius: 10px;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.section {
background: white;
padding: 20px;
margin-bottom: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.section h2 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
input, select, textarea {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 100px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
button {
background: #667eea;
color: white;
padding: 12px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover {
background: #5a6fd8;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
}
.btn-secondary:hover {
background: #5a6268;
}
.results {
margin-top: 20px;
}
.table-container {
overflow-x: auto;
margin-top: 15px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
word-wrap: break-word;
}
th:nth-child(1), td:nth-child(1) { min-width: 180px; } /* Path */
th:nth-child(2), td:nth-child(2) { width: 60px; } /* Enabled */
th:nth-child(3), td:nth-child(3) { min-width: 100px; } /* Regimen */
th:nth-child(4), td:nth-child(4) { min-width: 120px; } /* Source */
th:nth-child(5), td:nth-child(5) { width: 90px; } /* Context */
th:nth-child(6), td:nth-child(6) { min-width: 120px; } /* Exclude MMSI */
th:nth-child(7), td:nth-child(7) { width: 140px; } /* Actions */
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:hover {
background-color: #f8f9fa;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.path-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 15px;
}
.path-item {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.path-item h4 {
margin-bottom: 5px;
color: #495057;
}
.path-item p {
font-size: 0.9rem;
color: #666;
}
.path-item button {
margin-top: 10px;
padding: 5px 10px;
font-size: 12px;
}
.query-examples {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-top: 15px;
}
.query-examples h4 {
margin-bottom: 10px;
color: #495057;
}
.query-examples ul {
list-style: none;
padding: 0;
}
.query-examples li {
margin-bottom: 10px;
padding: 10px;
background: white;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.3s;
}
.query-examples li:hover {
background: #e9ecef;
}
.query-examples code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
}
.stats {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
.collapsible-header {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
margin: -20px -20px 15px -20px;
background: #f8f9fa;
border-radius: 10px 10px 0 0;
transition: background-color 0.3s;
}
.collapsible-header:hover {
background: #e9ecef;
}
.collapsible-header h2 {
margin: 0;
}
.collapse-icon {
font-size: 1.2rem;
transition: transform 0.3s;
}
.collapse-icon.collapsed {
transform: rotate(-90deg);
}
.collapsible-content {
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.collapsible-content.collapsed {
max-height: 0;
}
.collapsible-content.expanded {
max-height: 2000px;
}
/* Tab Navigation Styles */
.tab-navigation {
display: flex;
background: white;
border-radius: 10px 10px 0 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 0;
overflow-x: auto;
}
.tab-button {
background: #f8f9fa;
border: none;
padding: 15px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-radius: 0;
transition: all 0.3s;
white-space: nowrap;
border-bottom: 3px solid transparent;
margin: 0;
}
.tab-button:first-child {
border-radius: 10px 0 0 0;
}
.tab-button:last-child {
border-radius: 0 10px 0 0;
}
.tab-button:hover {
background: #e9ecef;
color: #333;
}
.tab-button.active {
background: white;
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
background: white;
border-radius: 0 0 10px 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.tab-panel .section {
margin-bottom: 0;
box-shadow: none;
border-radius: 0 0 10px 10px;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header h1 {
font-size: 2rem;
}
.stats {
flex-direction: column;
gap: 10px;
}
.tab-navigation {
flex-wrap: wrap;
}
.tab-button {
flex: 1;
min-width: 120px;
padding: 12px 15px;
font-size: 13px;
}
.tab-button:first-child,
.tab-button:last-child {
border-radius: 0;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>
<img src="parquet.png" alt="Parquet" style="height: 80px; vertical-align: middle; margin-right: 10px; border-radius: 5px;">
SignalK Parquet Data Store</h1>
<p>Export, query, and explore your SignalK data archived as Parquet files</p>
</div>
<!-- Tab Navigation -->
<div class="tab-navigation">
<button class="tab-button active" onclick="showTab('pathConfig')">âī¸ Path Configuration</button>
<button class="tab-button" onclick="showTab('commandManager')">đŽ Command Manager</button>
<button class="tab-button" onclick="showTab('dataPaths')">đ Available Data Paths</button>
<button class="tab-button" onclick="showTab('customQuery')">đ Custom Query</button>
<button class="tab-button" onclick="showTab('s3Config')">âī¸ Cloud Status</button>
</div>
<!-- Tab Content Panels -->
<div class="tab-content">
<!-- Path Configuration Tab -->
<div id="pathConfig" class="tab-panel active">
<div class="section">
<h2>âī¸ Path Configuration</h2>
<p>Manage which SignalK paths to collect data from:</p>
<button onclick="loadPathConfigurations()">đ Refresh Paths</button>
<button onclick="showAddPathForm()">â Add New Path</button>
<button id="toggleCommandsBtn" onclick="toggleCommandPaths()">đī¸ Show Commands</button>
<!-- Add Path Form (hidden by default) -->
<div id="addPathForm" style="display: none; margin: 20px 0; padding: 20px; background: #f8f9fa; border-radius: 5px; min-height: 400px; overflow: visible;">
<h4>Add New Path Configuration</h4>
<div class="form-group">
<label for="pathSignalK">SignalK Path:</label>
<input type="text" id="pathSignalK" placeholder="e.g., navigation.position">
</div>
<div class="form-group">
<label for="pathEnabled" style="display: inline !important; margin-bottom: 0 !important;">Always Enabled:</label>
<input type="checkbox" id="pathEnabled" style="display: inline !important; width: auto !important; margin-left: 10px;">
</div>
<div class="form-group">
<label for="pathRegimen">Regimen Control:</label>
<input type="text" id="pathRegimen" placeholder="e.g., captureWeather, capturePassage">
</div>
<div class="form-group">
<label for="pathContext">Context:</label>
<input type="text" id="pathContext" placeholder="e.g., vessels.self or vessels.*" value="vessels.self">
</div>
<div class="form-group">
<label for="pathSource">Source Filter (optional):</label>
<input type="text" id="pathSource" placeholder="e.g., mqtt-weatherflow-udp">
<small style="color: #666; display: block; margin-top: 5px;">
Filter by specific data source. Leave empty to accept all sources.
</small>
</div>
<div class="form-group">
<label for="pathExcludeMMSI">Exclude MMSI (for vessels.* context):</label>
<input type="text" id="pathExcludeMMSI" placeholder="e.g., 123456789, 987654321 (comma-separated)">
<small style="color: #666; display: block; margin-top: 5px;">
Only applicable when context is "vessels.*". Leave empty to include all vessels.
</small>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #ddd; min-height: 60px; display: flex; gap: 10px; align-items: center;">
<button onclick="addPathConfiguration()">â
Add Path</button>
<button class="btn-secondary" onclick="hideAddPathForm()">â Cancel</button>
</div>
</div>
<div id="pathConfigContainer">
<div class="loading">Loading path configurations...</div>
</div>
</div>
</div>
<!-- Command Manager Tab -->
<div id="commandManager" class="tab-panel">
<div class="section">
<h2>đŽ Command Manager</h2>
<p>Register and manage SignalK commands for remote control:</p>
<button onclick="loadCommands()">đ Refresh Commands</button>
<button onclick="showAddCommandForm()">â Add New Command</button>
<!-- Add Command Form (hidden by default) -->
<div id="addCommandForm" style="display: none; margin: 20px 0; padding: 20px; background: #f8f9fa; border-radius: 5px;">
<h4>Register New Command</h4>
<div class="form-group">
<label for="commandName">Command Name:</label>
<input type="text" id="commandName" placeholder="e.g., captureWeather, startLogging">
</div>
<div class="form-group">
<label for="commandDescription">Description (optional):</label>
<input type="text" id="commandDescription" placeholder="e.g., Start weather data capture">
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #ddd;">
<button onclick="registerCommand()">â
Register Command</button>
<button class="btn-secondary" onclick="hideAddCommandForm()">â Cancel</button>
</div>
</div>
<div id="commandContainer">
<div class="loading">Loading commands...</div>
</div>
<!-- Command History -->
<div style="margin-top: 30px;">
<h3>đ Command History</h3>
<button onclick="loadCommandHistory()">đ Refresh History</button>
<div id="commandHistoryContainer" style="margin-top: 15px;">
<div class="loading">Loading command history...</div>
</div>
</div>
</div>
</div>
<!-- S3 Configuration Tab -->
<div id="s3Config" class="tab-panel">
<div class="section">
<h2>âī¸ Cloud Status</h2>
<p>Test your S3 connection and understand the Key Prefix functionality:</p>
<button onclick="testS3Connection()">đ Test S3 Connection</button>
<div id="s3TestResult" style="margin-top: 10px;"></div>
<div style="margin-top: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<h4>đ S3 Key Prefix Explanation</h4>
<p><strong>S3 Key Prefix</strong> organizes your uploaded files in the S3 bucket with a directory structure:</p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li><strong>Without prefix:</strong> Files upload as <code>vessels/self/navigation/position/2025-07-12.parquet</code></li>
<li><strong>With prefix "marine-data":</strong> Files upload as <code>marine-data/vessels/self/navigation/position/2025-07-12.parquet</code></li>
<li><strong>With prefix "boat-123/":</strong> Files upload as <code>boat-123/vessels/self/navigation/position/2025-07-12.parquet</code></li>
</ul>
<p>This allows organizing multiple vessels or data sources in the same S3 bucket.</p>
</div>
</div>
</div>
<!-- Data Paths Tab -->
<div id="dataPaths" class="tab-panel">
<div class="section">
<h2>đ Available Data Paths</h2>
<p>Available SignalK data paths with Parquet files:</p>
<div id="availablePaths">
<div class="loading">Loading available paths...</div>
</div>
<!-- Query Section for Data Paths -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">
<h3>Query Data</h3>
<div class="form-group">
<label for="dataPathsQueryInput">DuckDB SQL Query:</label>
<textarea id="dataPathsQueryInput" placeholder="SELECT * FROM 'path/to/file.parquet' LIMIT 10"></textarea>
</div>
<button onclick="executeDataPathsQuery()">âļī¸ Execute Query</button>
<button class="btn-secondary" onclick="clearDataPathsQuery()">đī¸ Clear</button>
<div id="dataPathsResultsContainer" class="results">
<p>Run a query to see results here...</p>
</div>
</div>
</div>
</div>
<!-- Custom Query Tab -->
<div id="customQuery" class="tab-panel">
<div class="section">
<h2>đ Custom Query</h2>
<div class="form-group">
<label for="queryInput">DuckDB SQL Query:</label>
<textarea id="queryInput" placeholder="SELECT * FROM 'path/to/file.parquet' LIMIT 10"></textarea>
</div>
<button onclick="executeQuery()">âļī¸ Execute Query</button>
<button class="btn-secondary" onclick="clearQuery()">đī¸ Clear</button>
<div class="query-examples">
<h4>đ Example Queries (click to use):</h4>
<ul id="queryExamples">
<li><div class="loading">Loading example queries...</div></li>
</ul>
</div>
<!-- Query Results Section -->
<div id="customQueryResultsContainer" class="results">
<p>Run a query to see results here...</p>
</div>
</div>
</div>
</div>
</div>
<script>
let availablePaths = [];
let dataDirectory = '';
let showCommandPaths = false; // Default to hiding command paths
// Get the plugin path dynamically
function getPluginPath() {
// Get the current script's path to determine the plugin name
const currentPath = window.location.pathname;
const pathParts = currentPath.split('/');
const pluginIndex = pathParts.indexOf('plugins');
if (pluginIndex !== -1 && pathParts[pluginIndex + 1]) {
return `/plugins/${pathParts[pluginIndex + 1]}`;
}
// Fallback to the plugin name from package.json
return '/plugins/signalk-parquet';
}
// Load available paths on page load
window.addEventListener('load', async function() {
await loadAvailablePaths();
await loadPathConfigurations();
generateExampleQueries();
});
async function loadAvailablePaths() {
try {
const response = await fetch(`${getPluginPath()}/api/paths`);
const result = await response.json();
if (result.success) {
availablePaths = result.paths;
dataDirectory = result.dataDirectory;
displayAvailablePaths();
} else {
document.getElementById('availablePaths').innerHTML =
`<div class="error">Error loading paths: ${result.error}</div>`;
}
} catch (error) {
document.getElementById('availablePaths').innerHTML =
`<div class="error">Network error: ${error.message}</div>`;
}
}
function displayAvailablePaths() {
const container = document.getElementById('availablePaths');
if (availablePaths.length === 0) {
container.innerHTML = '<p>No Parquet data files found. Start collecting data first.</p>';
return;
}
let html = '<div class="path-list">';
availablePaths.forEach(pathInfo => {
html += `
<div class="path-item">
<h4>${pathInfo.path}</h4>
<p>${pathInfo.fileCount} files</p>
<button onclick="generateQueryForPath('${pathInfo.path}', '${pathInfo.directory}')">đ Generate Query</button>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function generateQueryForPath(signalkPath, directory) {
const query = `SELECT * FROM '${directory}/*.parquet' ORDER BY received_timestamp DESC LIMIT 10`;
setDataPathsQuery(query);
}
function generateExampleQueries() {
const container = document.getElementById('queryExamples');
if (availablePaths.length === 0) {
container.innerHTML = '<li><em>No data paths available yet. Start collecting data first.</em></li>';
return;
}
let html = '';
// Generate examples based on available paths
availablePaths.slice(0, 4).forEach(pathInfo => {
const examples = [
`SELECT * FROM '${pathInfo.directory}/*.parquet' ORDER BY received_timestamp DESC LIMIT 10`,
`SELECT COUNT(*) as total_records FROM '${pathInfo.directory}/*.parquet'`,
`SELECT received_timestamp, value, source_label FROM '${pathInfo.directory}/*.parquet' WHERE value IS NOT NULL ORDER BY received_timestamp DESC LIMIT 10`
];
examples.forEach(query => {
html += `<li onclick="setQuery(this.textContent)"><code>${query}</code></li>`;
});
});
container.innerHTML = html;
}
async function executeQuery() {
const query = document.getElementById('queryInput').value.trim();
if (!query) {
alert('Please enter a query');
return;
}
const resultsContainer = document.getElementById('customQueryResultsContainer');
resultsContainer.innerHTML = '<div class="loading">Executing query...</div>';
try {
const response = await fetch('/plugins/signalk-parquet/api/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query })
});
const result = await response.json();
if (result.success) {
displayResults(result, 'customQueryResultsContainer');
} else {
resultsContainer.innerHTML = `<div class="error">Query error: ${result.error}</div>`;
}
} catch (error) {
resultsContainer.innerHTML = `<div class="error">Network error: ${error.message}</div>`;
}
}
async function executeDataPathsQuery() {
const query = document.getElementById('dataPathsQueryInput').value.trim();
if (!query) {
alert('Please enter a query');
return;
}
const resultsContainer = document.getElementById('dataPathsResultsContainer');
resultsContainer.innerHTML = '<div class="loading">Executing query...</div>';
try {
const response = await fetch('/plugins/signalk-parquet/api/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query })
});
const result = await response.json();
if (result.success) {
displayResults(result, 'dataPathsResultsContainer');
} else {
resultsContainer.innerHTML = `<div class="error">Query error: ${result.error}</div>`;
}
} catch (error) {
resultsContainer.innerHTML = `<div class="error">Network error: ${error.message}</div>`;
}
}
function displayResults(result, containerId = 'resultsContainer') {
const container = document.getElementById(containerId);
if (!result.data || result.data.length === 0) {
container.innerHTML = '<p>No data returned from query.</p>';
return;
}
// Stats
let statsHtml = `
<div class="stats">
<div class="stat-item">
<div class="stat-value">${result.rowCount}</div>
<div class="stat-label">Rows</div>
</div>
<div class="stat-item">
<div class="stat-value">${Object.keys(result.data[0]).length}</div>
<div class="stat-label">Columns</div>
</div>
</div>
`;
// Table
const columns = Object.keys(result.data[0]);
let tableHtml = '<div class="table-container"><table><thead><tr>';
columns.forEach(col => {
tableHtml += `<th>${col}</th>`;
});
tableHtml += '</tr></thead><tbody>';
result.data.forEach(row => {
tableHtml += '<tr>';
columns.forEach(col => {
let value = row[col];
if (value === null || value === undefined) {
value = '';
} else if (typeof value === 'object') {
value = JSON.stringify(value);
}
tableHtml += `<td>${value}</td>`;
});
tableHtml += '</tr>';
});
tableHtml += '</tbody></table></div>';
container.innerHTML = statsHtml + tableHtml;
}
function setQuery(query) {
document.getElementById('queryInput').value = query;
}
function setDataPathsQuery(query) {
document.getElementById('dataPathsQueryInput').value = query;
}
function clearQuery() {
document.getElementById('queryInput').value = '';
document.getElementById('customQueryResultsContainer').innerHTML = '<p>Run a query to see results here...</p>';
}
function clearDataPathsQuery() {
document.getElementById('dataPathsQueryInput').value = '';
document.getElementById('dataPathsResultsContainer').innerHTML = '<p>Run a query to see results here...</p>';
}
function showTab(tabId) {
// Hide all tab panels
const tabPanels = document.querySelectorAll('.tab-panel');
tabPanels.forEach(panel => {
panel.classList.remove('active');
});
// Remove active class from all tab buttons
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.classList.remove('active');
});
// Show the selected tab panel
const selectedPanel = document.getElementById(tabId);
if (selectedPanel) {
selectedPanel.classList.add('active');
}
// Add active class to the clicked tab button
const clickedButton = document.querySelector(`[onclick="showTab('${tabId}')"]`);
if (clickedButton) {
clickedButton.classList.add('active');
}
}
async function testS3Connection() {
const resultDiv = document.getElementById('s3TestResult');
const button = document.querySelector('button[onclick="testS3Connection()"]');
// Show loading state
button.disabled = true;
button.textContent = 'đ Testing...';
resultDiv.innerHTML = '<div class="loading">Testing S3 connection...</div>';
try {
const response = await fetch('/plugins/signalk-parquet/api/test-s3', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const result = await response.json();
if (result.success) {
resultDiv.innerHTML = `
<div class="success">
â
${result.message}<br>
<strong>Bucket:</strong> ${result.bucket}<br>
<strong>Region:</strong> ${result.region}<br>
<strong>Key Prefix:</strong> ${result.keyPrefix}
</div>
`;
} else {
resultDiv.innerHTML = `<div class="error">â ${result.error}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="error">â Network error: ${error.message}</div>`;
} finally {
// Reset button
button.disabled = false;
button.textContent = 'đ Test S3 Connection';
}
}
// Path Configuration Management Functions
async function loadPathConfigurations() {
try {
const response = await fetch('/plugins/signalk-parquet/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;
}
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();
}
function showAddPathForm() {
document.getElementById('addPathForm').style.display = 'block';
}
function hideAddPathForm() {
document.getElementById('addPathForm').style.display = 'none';
clearAddPathForm();
}
function clearAddPathForm() {
document.getElementById('pathSignalK').value = '';
document.getElementById('pathEnabled').checked = false;
document.getElementById('pathRegimen').value = '';
document.getElementById('pathSource').value = '';
document.getElementById('pathContext').value = 'vessels.self';
document.getElementById('pathExcludeMMSI').value = '';
}
async function addPathConfiguration() {
const excludeMMSIInput = document.getElementById('pathExcludeMMSI').value.trim();
const excludeMMSI = excludeMMSIInput ? excludeMMSIInput.split(',').map(mmsi => mmsi.trim()).filter(mmsi => mmsi) : [];
const pathConfig = {
path: document.getElementById('pathSignalK').value.trim(),
enabled: document.getElementById('pathEnabled').checked,
regimen: document.getElementById('pathRegimen').value.trim(),
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('/plugins/signalk-parquet/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}`);
}
}
async function removePathConfiguration(index) {
if (!confirm('Are you sure you want to remove this path configuration?')) {
return;
}
try {
const response = await fetch(`/plugins/signalk-parquet/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;
async function editPathConfiguration(index) {
// Cancel any existing edit
if (editingIndex !== -1) {
cancelEdit();
}
editingIndex = index;
// Get the current path configuration
const response = await fetch('/plugins/signalk-parquet/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(', ') : '';
row.innerHTML = `
<td><input type="text" id="editPath${index}" value="${path.path || ''}" style="width: 100%;"></td>
<td><input type="checkbox" id="editEnabled${index}" ${path.enabled ? 'checked' : ''}></td>
<td><input type="text" id="editRegimen${index}" value="${path.regimen || ''}" style="width: 100%;"></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>
`;
}
}
async function saveEdit(index) {
const excludeMMSIInput = document.getElementById(`editExcludeMMSI${index}`).value.trim();
const excludeMMSI = excludeMMSIInput ? excludeMMSIInput.split(',').map(mmsi => mmsi.trim()).filter(mmsi => mmsi) : [];
const updatedPath = {
path: document.getElementById(`editPath${index}`).value.trim(),
enabled: document.getElementById(`editEnabled${index}`).checked,
regimen: document.getElementById(`editRegimen${index}`).value.trim(),
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(`/plugins/signalk-parquet/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}`);
}
}
function cancelEdit() {
editingIndex = -1;
loadPathConfigurations();
}
// Command Management Functions
async function loadCommands() {
try {
const response = await fetch('/plugins/signalk-parquet/api/commands');
const result = await response.json();
if (result.success) {
displayCommands(result.commands || []);
} else {
document.getElementById('commandContainer').innerHTML = `<div class="error">Error loading commands: ${result.error}</div>`;
}
} catch (error) {
document.getElementById('commandContainer').innerHTML = `<div class="error">Error loading commands: ${error.message}</div>`;
}
}
function displayCommands(commands) {
const container = document.getElementById('commandContainer');
if (!commands || commands.length === 0) {
container.innerHTML = '<div class="info">No commands registered yet.</div>';
return;
}
let html = '<div class="table-container"><table><thead><tr>';
html += '<th>Command</th><th>Path</th><th>Description</th><th>Status</th><th>Registered</th><th>Actions</th>';
html += '</tr></thead><tbody>';
commands.forEach(command => {
const status = command.active ? 'đĸ Active' : 'đ´ Inactive';
const registeredDate = new Date(command.registered).toLocaleString();
html += `<tr>
<td><strong>${command.command}</strong></td>
<td><code>vessels.self.commands.${command.command}</code></td>
<td>${command.description || 'No description'}</td>
<td>${status}</td>
<td>${registeredDate}</td>
<td>
<button onclick="executeCommand('${command.command}', true)" style="margin-right: 5px;">âļī¸ Start</button>
<button onclick="executeCommand('${command.command}', false)" style="margin-right: 5px;">âšī¸ Stop</button>
<button onclick="unregisterCommand('${command.command}')" class="btn-danger">â Remove</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function showAddCommandForm() {
document.getElementById('addCommandForm').style.display = 'block';
}
function hideAddCommandForm() {
document.getElementById('addCommandForm').style.display = 'none';
clearAddCommandForm();
}
function clearAddCommandForm() {
document.getElementById('commandName').value = '';
document.getElementById('commandDescription').value = '';
}
async function registerCommand() {
try {
const command = document.getElementById('commandName').value.trim();
const description = document.getElementById('commandDescription').value.trim();
if (!command) {
alert('Command name is required');
return;
}
const response = await fetch('/plugins/signalk-parquet/api/commands', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
command: command,
description: description || undefined
})
});
const result = await response.json();
if (result.success) {
hideAddCommandForm();
await loadCommands();
await loadPathConfigurations(); // Refresh paths to show the auto-created path
alert(`Command '${command}' registered successfully!\n\nA path configuration has been automatically created and enabled for this command.`);
} else {
alert(`Error registering command: ${result.error}