UNPKG

signalk-parquet

Version:

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

539 lines (490 loc) 19.3 kB
/** * GPX Import UI * * Primary flow: user picks (or drops) local .gpx files and clicks Start Import. * Files are posted multipart to /api/import/gpx/upload; the backend stages * them and kicks off the existing import job pipeline. * * Advanced flow: user enters a server-side directory path. This is useful * for bulk imports from mounted USB drives or existing track archives on * the Signal K host. */ let currentGpxImportJobId = localStorage.getItem('gpxImportJobId') || null; let gpxImportPollInterval = null; let gpxSelectedFiles = []; let gpxUploadInFlight = false; // Human labels for the backend "phase" field — the service uses internal // short names, but users shouldn't see "parse" or "write" bare. const GPX_PHASE_LABEL = { scan: 'Finding files', parse: 'Reading files', write: 'Writing parquet', aggregate: 'Building tiers', }; function getSelectedGpxPaths() { return Array.from(document.querySelectorAll('.gpxPath')) .filter(cb => cb.checked) .map(cb => cb.value); } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } function renderSelectedFiles() { const el = document.getElementById('gpxSelectedFiles'); if (!el) return; // Use textContent/DOM API instead of innerHTML so filenames from the user's // disk can't smuggle HTML into the page. el.textContent = ''; if (gpxSelectedFiles.length === 0) { el.textContent = 'No files selected'; return; } const total = gpxSelectedFiles.reduce((sum, f) => sum + f.size, 0); const header = document.createElement('strong'); header.textContent = `${gpxSelectedFiles.length} file(s), ${formatBytes(total)}`; el.appendChild(header); const shown = Math.min(10, gpxSelectedFiles.length); for (let i = 0; i < shown; i++) { const f = gpxSelectedFiles[i]; const row = document.createElement('div'); row.textContent = `• ${f.name} (${formatBytes(f.size)})`; el.appendChild(row); } if (gpxSelectedFiles.length > shown) { const more = document.createElement('div'); more.style.color = '#999'; more.style.marginTop = '4px'; more.textContent = `…and ${gpxSelectedFiles.length - shown} more`; el.appendChild(more); } } function refreshStartButtonState() { const btn = document.getElementById('startGpxImportBtn'); if (!btn) return; btn.disabled = gpxUploadInFlight || currentGpxImportJobId !== null || (gpxSelectedFiles.length === 0 && !serverDirValue()); } function setGpxFiles(fileList) { const all = Array.from(fileList); const accepted = all.filter(f => f.name.toLowerCase().endsWith('.gpx')); const rejected = all.length - accepted.length; gpxSelectedFiles = accepted; // Surface a clear note when the user dropped non-.gpx files (or a folder, // which appears as zero files in dataTransfer.files). const note = document.getElementById('gpxRejectedNote'); if (note) { if (all.length === 0) { note.textContent = 'Nothing to import — folders can’t be dragged in, please pick .gpx files directly.'; note.style.display = 'block'; } else if (rejected > 0) { note.textContent = `Ignored ${rejected} non-.gpx file(s).`; note.style.display = 'block'; } else { note.style.display = 'none'; } } renderSelectedFiles(); refreshStartButtonState(); } function serverDirValue() { const el = document.getElementById('gpxSourceDirectory'); return el ? el.value.trim() : ''; } function initGpxDropZone() { const zone = document.getElementById('gpxDropZone'); const input = document.getElementById('gpxFileInput'); if (!zone || !input) return; const openPicker = () => input.click(); zone.addEventListener('click', openPicker); // Keyboard activation (Enter / Space) for screen-reader and keyboard users; // the zone carries role="button" + tabindex="0" in the HTML for this. zone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker(); } }); input.addEventListener('change', () => setGpxFiles(input.files)); zone.addEventListener('dragover', e => { e.preventDefault(); zone.style.background = '#f3e5f5'; zone.style.borderColor = '#6a1b9a'; }); zone.addEventListener('dragleave', () => { zone.style.background = '#fafafa'; zone.style.borderColor = '#8e24aa'; }); zone.addEventListener('drop', e => { e.preventDefault(); zone.style.background = '#fafafa'; zone.style.borderColor = '#8e24aa'; if (e.dataTransfer && e.dataTransfer.files) { setGpxFiles(e.dataTransfer.files); } }); // Also enable Start Import when the user types a server dir instead. const dirInput = document.getElementById('gpxSourceDirectory'); if (dirInput) { dirInput.addEventListener('input', refreshStartButtonState); } } // Warn the user if they try to close the tab during an upload — the browser // will abort the multipart request and leave temp files behind on the server. window.addEventListener('beforeunload', e => { if (gpxUploadInFlight) { e.preventDefault(); e.returnValue = ''; } }); // Map a failed fetch response into a user-friendly error string. 401 from // SignalK means the browser has no session cookie — surface a clear hint // instead of the generic "Failed to start import" with a cryptic message. async function explainHttpError(response) { if (response.status === 401) { return 'Not logged in. Open the Signal K admin UI, log in, then try again from this tab.'; } if (response.status === 413) { return 'Upload too large (over 200 MB per file or 500 files total).'; } try { const d = await response.json(); if (d && d.error) return d.error; } catch { // not JSON (e.g. HTML login page) } return `HTTP ${response.status} ${response.statusText || ''}`.trim(); } async function scanGpxImport() { // Only meaningful for the server-directory fallback. const sourceDir = serverDirValue(); const resultsDiv = document.getElementById('gpxScanResults'); const contentDiv = document.getElementById('gpxScanContent'); if (!sourceDir) { alert( 'Enter a server directory first (Advanced). For uploads, just click Start Import.' ); return; } resultsDiv.style.display = 'block'; contentDiv.innerHTML = '<p><span class="loading">Scanning files...</span></p>'; try { const response = await fetch( '/plugins/signalk-parquet/api/import/gpx/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceDirectory: sourceDir }), } ); if (!response.ok) { contentDiv.textContent = `Scan failed: ${await explainHttpError(response)}`; return; } const data = await response.json(); if (!data.success) { contentDiv.textContent = `Scan failed: ${data.error}`; return; } if (data.totalFiles === 0) { contentDiv.innerHTML = '<p style="color: #666;">No .gpx files found under that directory.</p>'; return; } // Render file list as DOM (not innerHTML) so user-supplied filenames // can't inject markup. const tbody = document.createElement('tbody'); for (const f of data.files.slice(0, 200)) { const tr = document.createElement('tr'); const tdName = document.createElement('td'); tdName.style.fontFamily = 'monospace'; tdName.style.fontSize = '0.9em'; tdName.textContent = f.name; const tdSize = document.createElement('td'); tdSize.style.textAlign = 'right'; tdSize.textContent = `${f.sizeMB} MB`; tr.appendChild(tdName); tr.appendChild(tdSize); tbody.appendChild(tr); } contentDiv.innerHTML = ` <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 15px;"> <div style="background: #f3e5f5; padding: 10px; border-radius: 5px; text-align: center;"> <strong>GPX Files Found</strong><br> <span style="font-size: 1.3em;">${data.totalFiles.toLocaleString()}</span> </div> <div style="background: #e1bee7; padding: 10px; border-radius: 5px; text-align: center;"> <strong>Total Size</strong><br> <span style="font-size: 1.3em;">${data.totalSizeMB} MB</span> </div> </div> <details> <summary style="cursor: pointer; color: #6a1b9a; font-weight: 500;">File list</summary> <table style="width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.9em;"> <thead> <tr style="background: #f5f5f5;"> <th style="text-align: left; padding: 8px; border-bottom: 2px solid #ddd;">File</th> <th style="text-align: right; padding: 8px; border-bottom: 2px solid #ddd;">Size</th> </tr> </thead> </table> </details> `; // Insert the safely-built tbody into the table we just rendered. const table = contentDiv.querySelector('table'); if (table) table.appendChild(tbody); if (data.files.length > 200) { const overflow = document.createElement('p'); overflow.style.color = '#666'; overflow.style.marginTop = '10px'; overflow.textContent = `Showing 200 of ${data.files.length} files`; contentDiv.appendChild(overflow); } } catch (error) { contentDiv.textContent = `Scan failed: ${error.message}`; } } async function startGpxImport() { const startBtn = document.getElementById('startGpxImportBtn'); const cancelBtn = document.getElementById('cancelGpxImportBtn'); const progressDiv = document.getElementById('gpxImportProgress'); const progressText = document.getElementById('gpxImportProgressText'); const progressBar = document.getElementById('gpxImportProgressBar'); const currentFileEl = document.getElementById('gpxImportCurrentFile'); // Disable the button immediately so a second click can't race us before // validation alerts fire and before we flip gpxUploadInFlight. startBtn.disabled = true; const sourceDir = serverDirValue(); const contextInput = document.getElementById('gpxContext').value.trim(); const paths = getSelectedGpxPaths(); const deleteSource = !!document.getElementById('gpxDeleteSource')?.checked; if (gpxSelectedFiles.length === 0 && !sourceDir) { alert( 'Pick at least one .gpx file, or set a server directory under Advanced.' ); refreshStartButtonState(); return; } if (paths.length === 0) { alert('Please select at least one Signal K path to emit.'); refreshStartButtonState(); return; } // Kick off the UI transition before the request so the user gets immediate // feedback even when the multipart body takes a while to send. progressDiv.style.display = 'block'; progressBar.style.width = '0%'; progressBar.style.background = 'linear-gradient(90deg, #8e24aa, #ba68c8)'; if (gpxSelectedFiles.length > 0) { const total = gpxSelectedFiles.reduce((s, f) => s + f.size, 0); progressText.textContent = `Uploading ${gpxSelectedFiles.length} file(s), ${formatBytes(total)}…`; } else { progressText.textContent = 'Starting…'; } if (currentFileEl) currentFileEl.textContent = ''; cancelBtn.disabled = false; try { let response; gpxUploadInFlight = true; if (gpxSelectedFiles.length > 0) { const form = new FormData(); for (const f of gpxSelectedFiles) { form.append('files', f, f.name); } if (contextInput) form.append('context', contextInput); form.append('paths', paths.join(',')); response = await fetch( '/plugins/signalk-parquet/api/import/gpx/upload', { method: 'POST', body: form } ); } else { response = await fetch('/plugins/signalk-parquet/api/import/gpx', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceDirectory: sourceDir, context: contextInput || undefined, paths, deleteSource, }), }); } gpxUploadInFlight = false; if (!response.ok) { alert(`Failed to start import: ${await explainHttpError(response)}`); progressDiv.style.display = 'none'; cancelBtn.disabled = true; refreshStartButtonState(); return; } const data = await response.json(); if (!data.success) { alert(`Failed to start import: ${data.error}`); progressDiv.style.display = 'none'; cancelBtn.disabled = true; refreshStartButtonState(); return; } currentGpxImportJobId = data.jobId; localStorage.setItem('gpxImportJobId', data.jobId); gpxImportPollInterval = setInterval(pollGpxImportProgress, 1000); } catch (error) { gpxUploadInFlight = false; alert(`Failed to start import: ${error.message}`); progressDiv.style.display = 'none'; cancelBtn.disabled = true; refreshStartButtonState(); } } async function pollGpxImportProgress() { if (!currentGpxImportJobId) return; try { const response = await fetch( `/plugins/signalk-parquet/api/import/gpx/progress/${currentGpxImportJobId}` ); // 404 = job TTL-evicted. Stop polling silently. if (response.status === 404) { clearInterval(gpxImportPollInterval); gpxImportPollInterval = null; currentGpxImportJobId = null; localStorage.removeItem('gpxImportJobId'); document.getElementById('gpxImportProgress').style.display = 'none'; document.getElementById('cancelGpxImportBtn').disabled = true; refreshStartButtonState(); return; } if (!response.ok) { // transient error — don't kill the poll loop over one bad response return; } const data = await response.json(); updateGpxImportProgress(data); if ( data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' ) { clearInterval(gpxImportPollInterval); gpxImportPollInterval = null; currentGpxImportJobId = null; localStorage.removeItem('gpxImportJobId'); document.getElementById('cancelGpxImportBtn').disabled = true; if (data.status === 'completed') { const parquetCount = (data.filesCreated || []).length; const hint = samplePartitionPath(data.filesCreated); alert( `Import complete.\n\n` + `Files imported: ${data.filesImported}\n` + `Files skipped: ${data.filesSkipped}\n` + `Points parsed: ${Number(data.pointsParsed).toLocaleString()}\n` + `Records written: ${Number(data.recordsWritten).toLocaleString()}\n` + `Parquet files: ${parquetCount}\n` + (hint ? `\nFiles landed under:\n ${hint}` : '') ); gpxSelectedFiles = []; renderSelectedFiles(); const note = document.getElementById('gpxRejectedNote'); if (note) note.style.display = 'none'; } else if (data.status === 'cancelled') { alert('Import cancelled.'); } else if (data.status === 'error') { alert(`Import failed: ${data.error}`); } refreshStartButtonState(); } } catch (error) { console.error('Error polling GPX import progress:', error); } } // Extract the common "tier=raw/context=.../path=.../" prefix from the list // of parquet paths produced, so the completion dialog can tell the user // roughly where the data went without dumping 16 full paths. function samplePartitionPath(files) { if (!Array.isArray(files) || files.length === 0) return ''; // Take the first file's path up through the last "year=" segment's parent. const parts = files[0].split(/[\\/]/); const yearIdx = parts.findIndex(p => p.startsWith('year=')); if (yearIdx <= 0) return files[0]; return parts.slice(0, yearIdx).join('/') + '/…'; } function updateGpxImportProgress(data) { const progressBar = document.getElementById('gpxImportProgressBar'); const progressText = document.getElementById('gpxImportProgressText'); const currentFile = document.getElementById('gpxImportCurrentFile'); const stats = document.getElementById('gpxImportStats'); const phase = GPX_PHASE_LABEL[data.phase] || data.phase || ''; if (data.phase === 'aggregate') { // Switch the bar to date-based progress; the file-percent has already // hit 100% by this phase and would otherwise sit frozen. const aggDone = data.aggregationDatesProcessed || 0; const aggTotal = data.aggregationDatesTotal || 0; const aggPercent = aggTotal > 0 ? Math.round((aggDone / aggTotal) * 100) : 0; progressBar.style.width = `${aggPercent}%`; progressText.textContent = `Building tiers: ${aggDone}/${aggTotal} dates (${aggPercent}%)`; currentFile.textContent = data.aggregationCurrentDate ? `Current date: ${data.aggregationCurrentDate}` : ''; } else { progressBar.style.width = `${data.percent}%`; progressText.textContent = `${data.percent}% complete (${data.processed}/${data.total} files` + (phase ? `, ${phase.toLowerCase()}` : '') + ')'; currentFile.textContent = data.currentFile ? `Current: ${data.currentFile}` : ''; } stats.textContent = `Points parsed: ${Number(data.pointsParsed).toLocaleString()} ` + `· Records written: ${Number(data.recordsWritten).toLocaleString()} ` + `· Output files: ${(data.filesCreated || []).length}`; if (data.status === 'running' || data.status === 'scanning') { progressBar.style.background = 'linear-gradient(90deg, #8e24aa, #ba68c8)'; } else if (data.status === 'completed') { progressBar.style.background = '#4caf50'; } else if (data.status === 'cancelled') { progressBar.style.background = '#ff9800'; } else if (data.status === 'error') { progressBar.style.background = '#f44336'; } } async function cancelGpxImport() { if (!currentGpxImportJobId) return; const cancelBtn = document.getElementById('cancelGpxImportBtn'); cancelBtn.disabled = true; // spam-proof try { const response = await fetch( `/plugins/signalk-parquet/api/import/gpx/cancel/${currentGpxImportJobId}`, { method: 'POST' } ); // 400 here means the job is already done — race with the completion // poll. Not worth alerting the user about. if (response.ok) { document.getElementById('gpxImportProgressText').textContent = 'Cancelling…'; } } catch (error) { // non-fatal; poll will pick up the final status anyway console.error('Cancel request failed:', error); } } window.scanGpxImport = scanGpxImport; window.startGpxImport = startGpxImport; window.cancelGpxImport = cancelGpxImport; document.addEventListener('DOMContentLoaded', () => { initGpxDropZone(); refreshStartButtonState(); if (currentGpxImportJobId) { const progressDiv = document.getElementById('gpxImportProgress'); if (progressDiv) { progressDiv.style.display = 'block'; document.getElementById('cancelGpxImportBtn').disabled = false; gpxImportPollInterval = setInterval(pollGpxImportProgress, 1000); } } });