UNPKG

signalk-parquet

Version:

Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.

613 lines (522 loc) 20.9 kB
import { getPluginPath } from './utils.js'; export async function loadStreams() { // DISABLED - Streaming functionality has been disabled // try { // const response = await fetch(`${getPluginPath()}/api/streams`); // const result = await response.json(); // if (result.success) { // activeStreams = result.streams || []; // displayStreams(activeStreams); // loadStreamStats(); // } else { // document.getElementById('streamsContainer').innerHTML = `<div class="error">Error loading streams: ${result.error}</div>`; // } // } catch (error) { // document.getElementById('streamsContainer').innerHTML = `<div class="error">Network error: ${error.message}</div>`; // } return; } export function showAddStreamForm() { document.getElementById('addStreamForm').style.display = 'block'; // Show/hide custom time range based on selection const timeRangeSelect = document.getElementById('streamTimeRange'); timeRangeSelect.addEventListener('change', function () { const customDiv = document.getElementById('customTimeRange'); customDiv.style.display = this.value === 'custom' ? 'block' : 'none'; }); } export function hideAddStreamForm() { document.getElementById('addStreamForm').style.display = 'none'; clearAddStreamForm(); } function clearAddStreamForm() { document.getElementById('streamName').value = ''; // Reset path field to dropdown if it was converted to manual input const pathField = document.getElementById('streamPath'); if (pathField.tagName === 'INPUT') { // Recreate the select element const pathContainer = pathField.parentNode; const pathSelect = document.createElement('select'); pathSelect.id = 'streamPath'; pathSelect.style.cssText = 'flex: 1;'; pathSelect.innerHTML = '<option value="">Select a SignalK path...</option>'; pathContainer.replaceChild(pathSelect, pathField); // Reload paths loadSignalKPaths(); } else { pathField.value = ''; } document.getElementById('streamTimeRange').value = '1h'; document.getElementById('streamResolution').value = '30000'; document.getElementById('streamRate').value = '500'; document.getElementById('streamAutoStart').checked = true; document.getElementById('customTimeRange').style.display = 'none'; document.getElementById('streamStartTime').value = ''; document.getElementById('streamEndTime').value = ''; } async function loadSignalKPaths() { const pathSelect = document.getElementById('streamPath'); try { pathSelect.innerHTML = '<option value="">Loading paths...</option>'; // Use the same endpoint as the Available Paths tab const response = await fetch(`${getPluginPath()}/api/paths`); const result = await response.json(); if ( result.success && Array.isArray(result.paths) && result.paths.length > 0 ) { // Clear loading message and add paths pathSelect.innerHTML = '<option value="">Select a SignalK path...</option>'; // Sort paths alphabetically for better UX const sortedPaths = result.paths.sort((a, b) => a.path.localeCompare(b.path) ); sortedPaths.forEach(pathInfo => { const option = document.createElement('option'); option.value = pathInfo.path; option.textContent = `${pathInfo.path} (${pathInfo.fileCount} files)`; pathSelect.appendChild(option); }); } else { // No paths found - provide manual entry option pathSelect.innerHTML = '<option value="">No historical paths found</option>'; const manualOption = document.createElement('option'); manualOption.value = 'manual'; manualOption.textContent = '✏️ Enter path manually'; pathSelect.appendChild(manualOption); } } catch (error) { console.error('Error loading SignalK paths:', error); // Provide manual entry option as fallback pathSelect.innerHTML = '<option value="">Select or enter a SignalK path...</option>'; const manualOption = document.createElement('option'); manualOption.value = 'manual'; manualOption.textContent = '✏️ Enter path manually'; pathSelect.appendChild(manualOption); } } async function loadVersionInfo() { try { const response = await fetch(`${getPluginPath()}/api/version`); if (response.ok) { const versionData = await response.json(); const headerElement = document.querySelector('.header h1'); if (headerElement) { const versionSpan = document.createElement('span'); versionSpan.style.fontSize = '0.4em'; versionSpan.style.color = 'rgb(200, 200, 200)'; versionSpan.style.fontWeight = 'normal'; versionSpan.style.marginLeft = '10px'; versionSpan.textContent = `v${versionData.version}`; headerElement.appendChild(versionSpan); } } } catch (error) { console.log('Could not load version information:', error); } } export async function refreshSignalKPaths() { await loadSignalKPaths(); } let liveConnectionsInitialized = false; export async function initLiveConnections() { if (liveConnectionsInitialized) { return; } liveConnectionsInitialized = true; await loadVersionInfo(); await loadSignalKPaths(); const pathSelect = document.getElementById('streamPath'); let manualInput = null; if (pathSelect) { pathSelect.addEventListener('change', function () { if (this.value === 'manual') { manualInput = document.createElement('input'); manualInput.type = 'text'; manualInput.id = 'streamPath'; manualInput.placeholder = 'Enter SignalK path manually (e.g., navigation.position)'; manualInput.style.cssText = this.style.cssText; this.parentNode.replaceChild(manualInput, this); manualInput.focus(); } }); } } export async function createStream() { const streamConfig = { name: document.getElementById('streamName').value.trim(), path: document.getElementById('streamPath').value.trim(), timeRange: document.getElementById('streamTimeRange').value, resolution: parseInt(document.getElementById('streamResolution').value), rate: parseInt(document.getElementById('streamRate').value), aggregateMethod: document.getElementById('streamAggregateMethod').value, windowSize: parseInt(document.getElementById('streamWindowSize').value), autoStart: document.getElementById('streamAutoStart').checked, }; // Handle custom time range if (streamConfig.timeRange === 'custom') { streamConfig.startTime = document.getElementById('streamStartTime').value; streamConfig.endTime = document.getElementById('streamEndTime').value; if (!streamConfig.startTime || !streamConfig.endTime) { alert('Custom time range requires both start and end times'); return; } } if (!streamConfig.name || !streamConfig.path) { alert('Stream name and path are required'); return; } // Streaming functionality has been disabled alert('Streaming functionality has been disabled'); } export async function startStream(streamId) { await streamAction(streamId, 'start'); } export async function pauseStream(streamId) { await streamAction(streamId, 'pause'); } export async function stopStream(streamId) { await streamAction(streamId, 'stop'); } export async function deleteStream(streamId) { if ( !confirm( 'Are you sure you want to delete this stream? This action cannot be undone.' ) ) { return; } await streamAction(streamId, 'delete'); } async function streamAction(streamId, action) { try { const method = action === 'delete' ? 'DELETE' : 'PUT'; const baseEndpoint = `${getPluginPath()}/api/streams/${streamId}`; const url = action === 'delete' ? baseEndpoint : `${baseEndpoint}/${action}`; const response = await fetch(url, { method }); const result = await response.json(); if (result.success) { // Streaming disabled - no need to refresh // Don't show alert for successful actions, just refresh the display } else { alert(`Error ${action}ing stream: ${result.error}`); } } catch (error) { alert(`Network error: ${error.message}`); } } export async function editStream(_streamId) { try { // Get the current stream configuration // Streaming functionality has been disabled alert('Streaming functionality has been disabled'); return; // Note: Code below is unreachable due to disabled streaming // Kept for reference if streaming is re-enabled /* if (!result.success) { alert('Error loading stream data'); return; } const stream = result.streams.find(s => s.id === streamId); if (!stream) { alert('Stream not found'); return; } // Populate the form with current values document.getElementById('streamName').value = stream.name || ''; // Handle path selection - check if it exists in dropdown const pathSelect = document.getElementById('streamPath'); const streamPath = stream.path || ''; let pathFound = false; for (let option of pathSelect.options) { if (option.value === streamPath) { pathSelect.value = streamPath; pathFound = true; break; } } // If path not found in dropdown, convert to manual input if (!pathFound && streamPath) { const manualInput = document.createElement('input'); manualInput.type = 'text'; manualInput.id = 'streamPath'; manualInput.value = streamPath; manualInput.placeholder = 'Enter SignalK path manually (e.g., navigation.position)'; manualInput.style.cssText = pathSelect.style.cssText; pathSelect.parentNode.replaceChild(manualInput, pathSelect); } document.getElementById('streamTimeRange').value = stream.timeRange || '1h'; document.getElementById('streamResolution').value = stream.resolution || 30000; document.getElementById('streamRate').value = stream.rate || 1000; document.getElementById('streamAggregateMethod').value = stream.aggregateMethod || 'average'; document.getElementById('streamWindowSize').value = stream.windowSize || 50; document.getElementById('streamAutoStart').checked = false; // Don't auto-start on edit // Handle custom time range if (stream.timeRange === 'custom') { document.getElementById('customTimeRange').style.display = 'block'; document.getElementById('streamStartTime').value = stream.startTime || ''; document.getElementById('streamEndTime').value = stream.endTime || ''; } else { document.getElementById('customTimeRange').style.display = 'none'; } // Show the form and change the create button to update document.getElementById('addStreamForm').style.display = 'block'; document.getElementById('addStreamButton').style.display = 'none'; // Change form title and button const formTitle = document.querySelector('#addStreamForm h4'); formTitle.textContent = `Edit Stream: ${stream.name}`; // Replace create button with update/cancel buttons const buttonContainer = document.querySelector( '#addStreamForm .form-group:last-child > div' ); buttonContainer.innerHTML = ` <button onclick="updateStream('${streamId}')">✅ Update Stream</button> <button class="btn-secondary" onclick="cancelEditStream()">❌ Cancel</button> `; */ } catch (error) { alert(`Error loading stream for editing: ${error.message}`); } } export async function updateStream(_streamId) { const streamConfig = { name: document.getElementById('streamName').value.trim(), path: document.getElementById('streamPath').value.trim(), timeRange: document.getElementById('streamTimeRange').value, resolution: parseInt(document.getElementById('streamResolution').value), rate: parseInt(document.getElementById('streamRate').value), aggregateMethod: document.getElementById('streamAggregateMethod').value, windowSize: parseInt(document.getElementById('streamWindowSize').value), autoRestart: true, // Restart if it was running }; // Handle custom time range if (streamConfig.timeRange === 'custom') { streamConfig.startTime = document.getElementById('streamStartTime').value; streamConfig.endTime = document.getElementById('streamEndTime').value; if (!streamConfig.startTime || !streamConfig.endTime) { alert('Custom time range requires both start and end times'); return; } } if (!streamConfig.name || !streamConfig.path) { alert('Stream name and path are required'); return; } // Streaming functionality has been disabled alert('Streaming functionality has been disabled'); } export function cancelEditStream() { // Hide the form and reset it hideAddStreamForm(); // Reset form title and buttons const formTitle = document.querySelector('#addStreamForm h4'); formTitle.textContent = 'Add New Stream Configuration'; const buttonContainer = document.querySelector( '#addStreamForm .form-group:last-child > div' ); buttonContainer.innerHTML = ` <button onclick="createStream()">✅ Create Stream</button> <button class="btn-secondary" onclick="hideAddStreamForm()">❌ Cancel</button> `; } // Live Data Display Functions let liveDataEntries = []; let liveDataPaused = false; function updateLiveDataDisplay() { const tbody = document.getElementById('liveDataBody'); const countElement = document.getElementById('liveDataCount'); countElement.textContent = liveDataEntries.length; if (liveDataEntries.length === 0) { tbody.innerHTML = ` <tr> <td colspan="6" style="padding: 20px; text-align: center; color: #666;"> No data streaming yet. Start a stream to see live data. </td> </tr> `; return; } let html = ''; liveDataEntries.forEach((entry, index) => { // Create date object and handle UTC timestamps properly const date = new Date(entry.timestamp); const now = new Date(); // Check if timestamp is from today const isToday = date.toDateString() === now.toDateString(); let timeStr; if (isToday) { // Today: just show time timeStr = date.toLocaleTimeString(); } else { // Not today: show "Aug 21, 6:25 PM" format const options = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }; timeStr = date.toLocaleString(undefined, options); } const valueStr = formatValue(entry.value); const emaStr = entry.ema !== null ? entry.ema.toFixed(3) : '-'; const smaStr = entry.sma !== null ? entry.sma.toFixed(3) : '-'; const rowClass = index % 2 === 0 ? 'background: #f9f9f9;' : ''; html += ` <tr style="${rowClass}"> <td style="padding: 6px 8px; border-bottom: 1px solid #eee; font-family: monospace;">${timeStr}</td> <td style="padding: 6px 8px; border-bottom: 1px solid #eee;"><strong>${entry.streamName}</strong></td> <td style="padding: 6px 8px; border-bottom: 1px solid #eee;"><code style="font-size: 11px;">${entry.path}</code></td> <td style="padding: 6px 8px; border-bottom: 1px solid #eee; font-family: monospace;">${valueStr}</td> <td style="padding: 6px 8px; border-bottom: 1px solid #eee; font-family: monospace; color: #007bff;">${emaStr}</td> <td style="padding: 6px 8px; border-bottom: 1px solid #eee; font-family: monospace; color: #28a745;">${smaStr}</td> </tr> `; }); tbody.innerHTML = html; } function formatValue(value) { if (value === null || value === undefined) { return '<em>null</em>'; } if (typeof value === 'object') { if (value.latitude !== undefined && value.longitude !== undefined) { // Position object return `${value.latitude.toFixed(6)}, ${value.longitude.toFixed(6)}`; } // Other objects - show as JSON return JSON.stringify(value); } if (typeof value === 'number') { return value.toFixed(3); } return String(value); } export function clearLiveData() { if (confirm('Clear all live data entries?')) { liveDataEntries = []; updateLiveDataDisplay(); } } export function toggleLiveDataPause() { liveDataPaused = !liveDataPaused; const btn = document.getElementById('pauseDataBtn'); btn.textContent = liveDataPaused ? '▶️ Resume' : '⏸️ Pause'; btn.style.background = liveDataPaused ? '#28a745' : '#ffc107'; } export function showDataSummary() { const panel = document.getElementById('dataSummaryPanel'); const content = document.getElementById('dataSummaryContent'); if (panel.style.display === 'none') { // Analyze the data const pathStats = {}; const streamTypes = { initial: 0, incremental: 0 }; let totalBuckets = 0; liveDataEntries.forEach(entry => { // Extract path and value const path = entry.path; const value = parseFloat(entry.value); const streamName = entry.streamName; if (!isNaN(value)) { if (!pathStats[path]) { pathStats[path] = { values: [], methods: new Set(), bucketCounts: [], }; } pathStats[path].values.push(value); // Extract method and bucket count from stream name const methodMatch = streamName.match(/(INITIAL|INCREMENTAL): (\w+)/); if (methodMatch) { const [, type, method] = methodMatch; pathStats[path].methods.add(method); streamTypes[type.toLowerCase()]++; } // Extract bucket count const bucketMatch = streamName.match(/\((\d+) buckets\)/); if (bucketMatch) { const bucketCount = parseInt(bucketMatch[1]); pathStats[path].bucketCounts.push(bucketCount); totalBuckets += bucketCount; } } }); let summaryHtml = '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">'; // Stream Statistics summaryHtml += '<div><strong>📈 Stream Activity:</strong><br/>'; summaryHtml += `Initial Loads: ${streamTypes.initial}<br/>`; summaryHtml += `Incremental Updates: ${streamTypes.incremental}<br/>`; summaryHtml += `Total Time Buckets: ${totalBuckets}</div>`; // Path Analysis summaryHtml += '<div><strong>🎯 Path Analysis:</strong><br/>'; Object.entries(pathStats).forEach(([path, stats]) => { const values = stats.values; const min = Math.min(...values).toFixed(3); const max = Math.max(...values).toFixed(3); const avg = (values.reduce((a, b) => a + b, 0) / values.length).toFixed( 3 ); const latest = values[0]?.toFixed(3) || 'N/A'; const methods = Array.from(stats.methods).join(', '); const avgBuckets = stats.bucketCounts.length > 0 ? ( stats.bucketCounts.reduce((a, b) => a + b, 0) / stats.bucketCounts.length ).toFixed(0) : 'N/A'; summaryHtml += `<strong>${path}:</strong><br/>`; summaryHtml += `• Methods: ${methods}<br/>`; summaryHtml += `• Range: ${min} - ${max}<br/>`; summaryHtml += `• Average: ${avg}<br/>`; summaryHtml += `• Latest: ${latest}<br/>`; summaryHtml += `• Avg Buckets: ${avgBuckets}<br/><br/>`; }); summaryHtml += '</div></div>'; // Data Trends summaryHtml += '<div style="margin-top: 15px;"><strong>📊 Data Interpretation:</strong><br/>'; summaryHtml += 'This shows your time-bucketed statistical streaming in action!<br/>'; summaryHtml += '• Each row represents a statistical calculation (MAX, AVG, etc.) for a time bucket<br/>'; summaryHtml += '• INITIAL = First load with full time window<br/>'; summaryHtml += '• INCREMENTAL = New buckets from sliding window updates<br/>'; summaryHtml += '• Values change as new data arrives and statistics are recalculated</div>'; content.innerHTML = summaryHtml; panel.style.display = 'block'; } else { panel.style.display = 'none'; } } // WebSocket connection for real-time streaming data function connectWebSocket() { // For now, use polling mode since we need a dedicated WebSocket server startLiveDataPolling(); } // Enhanced polling function that shows real time-bucketed streaming data points function startLiveDataPolling() { setInterval(async () => { if (liveDataPaused) return; // Streaming functionality has been disabled - no running streams return; }, 3000); // Check every 3 seconds } // Initialize WebSocket connection when page loads setTimeout(() => { connectWebSocket(); }, 1000);