UNPKG

signalk-parquet

Version:

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

1,219 lines 192 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.registerApiRoutes = registerApiRoutes; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const glob_1 = require("glob"); const express_1 = __importDefault(require("express")); const multer_1 = __importDefault(require("multer")); const path_discovery_1 = require("./utils/path-discovery"); const duckdb_pool_1 = require("./utils/duckdb-pool"); const migration_service_1 = require("./services/migration-service"); const constants_1 = require("./constants"); const gpx_import_service_1 = require("./services/gpx-import-service"); const compaction_service_1 = require("./services/compaction-service"); const aggregation_service_1 = require("./services/aggregation-service"); const hive_path_builder_1 = require("./utils/hive-path-builder"); const angular_paths_1 = require("./utils/angular-paths"); const commands_1 = require("./commands"); const data_handler_1 = require("./data-handler"); const path_helpers_1 = require("./utils/path-helpers"); const claude_analyzer_1 = require("./claude-analyzer"); const analysis_templates_1 = require("./analysis-templates"); const vessel_context_1 = require("./vessel-context"); const validationJobs = new Map(); let lastValidationViolations = []; const repairJobs = new Map(); const VALIDATION_JOB_TTL_MS = 10 * 60 * 1000; // Retain job metadata for 10 minutes function scheduleValidationJobCleanup(jobId) { setTimeout(() => { const job = validationJobs.get(jobId); if (job && job.status !== 'running') { validationJobs.delete(jobId); } }, VALIDATION_JOB_TTL_MS); } const REPAIR_JOB_TTL_MS = 10 * 60 * 1000; function scheduleRepairJobCleanup(jobId) { setTimeout(() => { const job = repairJobs.get(jobId); if (job && job.status !== 'running') { repairJobs.delete(jobId); } }, REPAIR_JOB_TTL_MS); } // Shared analyzer instance to maintain conversation state across requests let sharedAnalyzer = null; /** * Get or create the shared Claude analyzer instance */ function getSharedAnalyzer(config, app, dataDir, state) { if (!sharedAnalyzer) { sharedAnalyzer = new claude_analyzer_1.ClaudeAnalyzer({ apiKey: config.claudeIntegration.apiKey, model: migrateClaudeModel(config.claudeIntegration.model, app), maxTokens: config.claudeIntegration.maxTokens || 4000, temperature: config.claudeIntegration.temperature || 0.3, }, app, dataDir, state); app.debug('🔧 Created shared Claude analyzer instance'); } return sharedAnalyzer; } // AWS S3 for testing connection // eslint-disable-next-line @typescript-eslint/no-explicit-any let ListObjectsV2Command; const claude_models_1 = require("./claude-models"); // Helper function to migrate deprecated Claude model names function migrateClaudeModel(model, app) { const validatedModel = (0, claude_models_1.getValidClaudeModel)(model); if (model && validatedModel !== model) { app?.debug(`Auto-migrated Claude model ${model} to ${validatedModel}`); } return validatedModel; } // =========================================== // PROCESS MANAGEMENT UTILITIES // =========================================== function _startProcess(state, type, totalFiles) { // Check if another process is already running if (state.currentProcess?.isRunning) { return false; // Cannot start, another process is active } // Initialize new process state.currentProcess = { type, isRunning: true, startTime: new Date(), totalFiles, processedFiles: 0, cancelRequested: false, abortController: new AbortController(), }; return true; } function _updateProcessProgress(state, processedFiles, currentFile) { if (state.currentProcess?.isRunning) { state.currentProcess.processedFiles = processedFiles; state.currentProcess.currentFile = currentFile; } } function _finishProcess(state) { if (state.currentProcess) { state.currentProcess.isRunning = false; // Keep the process data for a short time for status queries setTimeout(() => { if (state.currentProcess && !state.currentProcess.isRunning) { state.currentProcess = undefined; } }, 30000); // Clear after 30 seconds } } function cancelProcess(state) { if (state.currentProcess?.isRunning) { state.currentProcess.cancelRequested = true; state.currentProcess.abortController?.abort(); return true; } return false; } function getProcessStatus(state) { if (!state.currentProcess) { return { success: true, isRunning: false, }; } const process = state.currentProcess; const progress = process.totalFiles && process.processedFiles !== undefined ? Math.round((process.processedFiles / process.totalFiles) * 100) : undefined; return { success: true, isRunning: process.isRunning, processType: process.type, startTime: process.startTime.toISOString(), totalFiles: process.totalFiles, processedFiles: process.processedFiles, currentFile: process.currentFile, progress, }; } function registerApiRoutes(router, state, app) { // Serve static files from public directory const publicPath = path.join(__dirname, '../public'); if (fs.existsSync(publicPath)) { router.use(express_1.default.static(publicPath)); } // Convert BigInt values to regular numbers for JSON serialization // eslint-disable-next-line @typescript-eslint/no-explicit-any function mapForJSON(rawData) { return rawData.map(row => { const convertedRow = {}; for (const [key, value] of Object.entries(row)) { convertedRow[key] = typeof value === 'bigint' ? Number(value) : value; } return convertedRow; }); } // Returns sorted parquet files for a SignalK path, or null if the directory doesn't exist function getDataFilesForPath(signalkPath) { const selfContextPath = app.selfContext .replace(/\./g, '/') .replace(/:/g, '_'); const pathDir = path.join(state.getDataDirPath(), selfContextPath, signalkPath.replace(/\./g, '/')); if (!fs.existsSync(pathDir)) return null; const files = fs .readdirSync(pathDir) .filter((file) => file.endsWith('.parquet')) .map((file) => { const filePath = path.join(pathDir, file); const stat = fs.statSync(filePath); return { name: file, path: filePath, size: stat.size, modified: stat.mtime.toISOString(), }; }) .sort((a, b) => b.modified.localeCompare(a.modified)); return { pathDir, files }; } // Get available SignalK paths router.get('/api/paths', (_, res) => { try { const dataDir = state.getDataDirPath(); const paths = (0, path_discovery_1.getAvailablePaths)(dataDir, app); return res.json({ success: true, dataDirectory: dataDir, paths: paths, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get files for a specific path router.get('/api/files/:path(*)', (req, res) => { try { const signalkPath = req.params.path; const result = getDataFilesForPath(signalkPath); if (!result) { return res.status(404).json({ success: false, error: `Path not found: ${signalkPath}`, }); } return res.json({ success: true, path: signalkPath, directory: result.pathDir, files: result.files, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get sample data from a specific file router.get('/api/sample/:path(*)', async (req, res) => { try { const signalkPath = req.params.path; const limit = parseInt(req.query.limit) || 10; const result = getDataFilesForPath(signalkPath); if (!result) { return res.status(404).json({ success: false, error: `Path not found: ${signalkPath}`, }); } if (result.files.length === 0) { return res.status(404).json({ success: false, error: `No parquet files found for path: ${signalkPath}`, }); } const sampleFile = result.files[0]; const query = `SELECT * FROM read_parquet('${sampleFile.path}', union_by_name=true) LIMIT ${limit}`; // Get connection from pool (spatial extension already loaded) const connection = await duckdb_pool_1.DuckDBPool.getConnection(); try { const reader = await connection.runAndReadAll(query); const rawData = reader.getRowObjects(); const data = mapForJSON(rawData); // Get column info const columns = data.length > 0 ? Object.keys(data[0]) : []; return res.json({ success: true, path: signalkPath, file: sampleFile.name, columns: columns, rowCount: data.length, data: data, }); } catch (err) { return res.status(400).json({ success: false, error: err.message, }); } finally { connection.disconnectSync(); } } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Query parquet data (raw SQL - disabled by default for security) // Enable with: SIGNALK_PARQUET_RAW_SQL=true router.post('/api/query', async (req, res) => { try { // Security: Raw SQL is disabled by default const rawSqlEnabled = process.env.SIGNALK_PARQUET_RAW_SQL === 'true' || state.currentConfig?.enableRawSql === true; if (!rawSqlEnabled) { return res.status(403).json({ success: false, error: 'Raw SQL queries are disabled. Enable in plugin settings or set SIGNALK_PARQUET_RAW_SQL=true.', }); } const { query } = req.body; if (!query) { return res.status(400).json({ success: false, error: 'Query is required', }); } const dataDir = state.getDataDirPath(); // Replace placeholder paths in query with actual file paths let processedQuery = query; // Find all quoted paths in the query that might be SignalK paths const pathMatches = query.match(/'([^']+)'/g); if (pathMatches) { pathMatches.forEach(match => { const quotedPath = match.slice(1, -1); // Remove quotes // If it looks like a SignalK path, convert to file path const selfContextPath = (0, path_helpers_1.toContextFilePath)(app.selfContext); if (quotedPath.includes(`/${selfContextPath}/`) || quotedPath.includes('.parquet')) { // It's already a file path, use as is return; } else if (quotedPath.includes('.') && !quotedPath.includes('/')) { // It's a SignalK path, convert to file path const filePath = (0, path_helpers_1.toParquetFilePath)(dataDir, selfContextPath, quotedPath); processedQuery = processedQuery.replace(match, `'${filePath}'`); } }); } // Get connection from pool (spatial extension already loaded) const connection = await duckdb_pool_1.DuckDBPool.getConnection(); try { const reader = await connection.runAndReadAll(processedQuery); const rawData = reader.getRowObjects(); const data = mapForJSON(rawData); return res.json({ success: true, query: processedQuery, rowCount: data.length, data: data, }); } catch (err) { app.error(`Query error: ${err}`); return res.status(400).json({ success: false, error: err.message, }); } finally { connection.disconnectSync(); } } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Check if raw SQL queries are enabled (for UI visibility) router.get('/api/query/enabled', (_req, res) => { const rawSqlEnabled = process.env.SIGNALK_PARQUET_RAW_SQL === 'true' || state.currentConfig?.enableRawSql === true; return res.json({ success: true, enabled: rawSqlEnabled, }); }); // Test cloud connection (S3 or R2) router.post('/api/test-cloud', async (_, res) => { try { if (!state.currentConfig) { return res.status(500).json({ success: false, error: 'Plugin not started or configuration not available', }); } const cloud = state.currentConfig.cloudUpload; if (cloud.provider === 'none') { return res.status(400).json({ success: false, error: 'Cloud upload is not enabled in configuration', }); } if (!ListObjectsV2Command || !state.cloudClient) { try { const awsS3 = await Promise.resolve().then(() => __importStar(require('@aws-sdk/client-s3'))); ListObjectsV2Command = awsS3.ListObjectsV2Command; } catch (importError) { return res.status(503).json({ success: false, error: 'Cloud client not available or not initialized', }); } } const listCommand = new ListObjectsV2Command({ Bucket: cloud.bucket, MaxKeys: 1, }); await state.cloudClient.send(listCommand); const label = cloud.provider.toUpperCase(); return res.json({ success: true, message: `${label} connection successful`, provider: cloud.provider, bucket: cloud.bucket, region: cloud.provider === 's3' ? cloud.region || 'us-east-1' : undefined, accountId: cloud.provider === 'r2' ? cloud.accountId : undefined, keyPrefix: cloud.keyPrefix || 'none', }); } catch (error) { return res.status(500).json({ success: false, error: error.message || 'Cloud connection failed', }); } }); const cloudCompareJobs = new Map(); // Start cloud compare job router.post('/api/cloud/compare', async (_req, res) => { try { const cloud = state.currentConfig?.cloudUpload; if (!cloud || cloud.provider === 'none') { return res.status(400).json({ success: false, error: 'Cloud upload is not enabled', }); } if (!state.cloudClient || !ListObjectsV2Command) { try { const awsS3 = await Promise.resolve().then(() => __importStar(require('@aws-sdk/client-s3'))); ListObjectsV2Command = awsS3.ListObjectsV2Command; } catch { return res.status(503).json({ success: false, error: 'Cloud client not available', }); } } const label = cloud.provider.toUpperCase(); const jobId = `cloud-compare-${Date.now()}`; const job = { id: jobId, status: 'scanning_local', phase: 'Scanning local files...', localFilesScanned: 0, localFilesTotal: 0, cloudObjectsScanned: 0, progress: 0, }; cloudCompareJobs.set(jobId, job); (async () => { try { const dataDir = state.getDataDirPath(); job.phase = 'Discovering local files...'; // Only scan hive-partitioned files (tier=X/context=Y/path=Z/year=YYYY/day=DDD/) const excludedDirs = [ '/processed/', '/repaired/', '/failed/', '/quarantine/', ]; const allLocalFiles = await (0, glob_1.glob)(path.join(dataDir, 'tier=*', '**', '*.parquet')); const localFiles = allLocalFiles.filter(f => !excludedDirs.some(dir => f.includes(dir))); job.localFilesTotal = localFiles.length; const localKeys = new Map(); for (let i = 0; i < localFiles.length; i++) { const filePath = localFiles[i]; const relativePath = path.relative(dataDir, filePath); let cloudKey = relativePath; if (cloud.keyPrefix) { const prefix = cloud.keyPrefix.endsWith('/') ? cloud.keyPrefix : `${cloud.keyPrefix}/`; cloudKey = `${prefix}${relativePath}`; } const stats = await fs.stat(filePath); localKeys.set(cloudKey, { path: filePath, size: stats.size }); job.localFilesScanned = i + 1; job.progress = Math.round(((i + 1) / localFiles.length) * 40); job.phase = `Scanning local files: ${i + 1}/${localFiles.length}`; } job.status = 'scanning_cloud'; job.phase = `Listing ${label} objects...`; const cloudKeys = new Set(); let continuationToken; let batches = 0; do { const listCommand = new ListObjectsV2Command({ Bucket: cloud.bucket, Prefix: cloud.keyPrefix || undefined, ContinuationToken: continuationToken, }); const response = await state.cloudClient.send(listCommand); batches++; if (response.Contents) { for (const obj of response.Contents) { if (obj.Key?.endsWith('.parquet')) { cloudKeys.add(obj.Key); } } } job.cloudObjectsScanned = cloudKeys.size; job.progress = 40 + Math.min(batches * 5, 40); job.phase = `Listing ${label} objects: ${cloudKeys.size} found...`; continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; } while (continuationToken); job.status = 'comparing'; job.phase = 'Comparing files...'; job.progress = 85; const localOnly = []; const cloudOnly = []; const synced = []; for (const [key, info] of localKeys) { if (cloudKeys.has(key)) { synced.push(key); } else { localOnly.push({ key, size: info.size }); } } for (const key of cloudKeys) { if (!localKeys.has(key)) { cloudOnly.push(key); } } const totalLocalSize = localOnly.reduce((sum, f) => sum + f.size, 0); job.status = 'completed'; job.phase = 'Complete'; job.progress = 100; job.result = { provider: cloud.provider, summary: { localTotal: localKeys.size, cloudTotal: cloudKeys.size, synced: synced.length, localOnly: localOnly.length, cloudOnly: cloudOnly.length, localOnlySizeMB: (totalLocalSize / 1024 / 1024).toFixed(2), }, localOnly: localOnly.slice(0, 100), cloudOnly: cloudOnly.slice(0, 100), hasMore: localOnly.length > 100 || cloudOnly.length > 100, }; setTimeout(() => cloudCompareJobs.delete(jobId), 5 * 60 * 1000); } catch (error) { job.status = 'error'; job.error = error.message; } })(); return res.json({ success: true, jobId }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get cloud compare job status router.get('/api/cloud/compare/:jobId', async (req, res) => { const job = cloudCompareJobs.get(req.params.jobId); if (!job) { return res.status(404).json({ success: false, error: 'Job not found' }); } return res.json({ success: true, ...job }); }); const cloudSyncJobs = new Map(); // Start cloud sync job router.post('/api/cloud/sync', async (req, res) => { try { const cloud = state.currentConfig?.cloudUpload; if (!cloud || cloud.provider === 'none') { return res.status(400).json({ success: false, error: 'Cloud upload is not enabled', }); } if (!state.cloudClient) { return res.status(503).json({ success: false, error: 'Cloud client not initialized', }); } let PutObjectCommand; try { const awsS3 = await Promise.resolve().then(() => __importStar(require('@aws-sdk/client-s3'))); PutObjectCommand = awsS3.PutObjectCommand; if (!ListObjectsV2Command) { ListObjectsV2Command = awsS3.ListObjectsV2Command; } } catch { return res.status(503).json({ success: false, error: 'S3 SDK not available', }); } const label = cloud.provider.toUpperCase(); const jobId = `cloud-sync-${Date.now()}`; const job = { id: jobId, status: 'preparing', phase: 'Finding files to sync...', filesTotal: 0, filesUploaded: 0, filesFailed: 0, currentFile: '', progress: 0, errors: [], }; cloudSyncJobs.set(jobId, job); const dataDir = state.getDataDirPath(); const { keys } = req.body; (async () => { try { const filesToSync = []; if (keys && keys.length > 0) { for (const key of keys) { const relativePath = cloud.keyPrefix ? key.replace(new RegExp(`^${cloud.keyPrefix}/?`), '') : key; const localPath = path.join(dataDir, relativePath); if (await fs.pathExists(localPath)) { filesToSync.push({ key, localPath }); } } } else { job.phase = 'Scanning local files...'; // Only sync hive-partitioned files (tier=X/context=Y/path=Z/year=YYYY/day=DDD/) const excludedDirs = [ '/processed/', '/repaired/', '/failed/', '/quarantine/', ]; const allLocalFiles = await (0, glob_1.glob)(path.join(dataDir, 'tier=*', '**', '*.parquet')); const localFiles = allLocalFiles.filter(f => !excludedDirs.some(dir => f.includes(dir))); job.phase = `Listing ${label} objects...`; job.progress = 10; const cloudKeys = new Set(); let continuationToken; do { const listCommand = new ListObjectsV2Command({ Bucket: cloud.bucket, Prefix: cloud.keyPrefix || undefined, ContinuationToken: continuationToken, }); const response = await state.cloudClient.send(listCommand); if (response.Contents) { for (const obj of response.Contents) { if (obj.Key) cloudKeys.add(obj.Key); } } continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; } while (continuationToken); job.phase = 'Comparing files...'; job.progress = 20; for (const localPath of localFiles) { const relativePath = path.relative(dataDir, localPath); let cloudKey = relativePath; if (cloud.keyPrefix) { const prefix = cloud.keyPrefix.endsWith('/') ? cloud.keyPrefix : `${cloud.keyPrefix}/`; cloudKey = `${prefix}${relativePath}`; } if (!cloudKeys.has(cloudKey)) { filesToSync.push({ key: cloudKey, localPath }); } } } job.filesTotal = filesToSync.length; job.status = 'uploading'; job.progress = 25; if (filesToSync.length === 0) { job.status = 'completed'; job.phase = 'No files to sync'; job.progress = 100; setTimeout(() => cloudSyncJobs.delete(jobId), 5 * 60 * 1000); return; } for (let i = 0; i < filesToSync.length; i++) { const { key, localPath } = filesToSync[i]; job.currentFile = path.basename(localPath); job.phase = `Uploading ${i + 1}/${filesToSync.length}: ${job.currentFile}`; job.progress = 25 + Math.round(((i + 1) / filesToSync.length) * 75); try { const fileContent = await fs.readFile(localPath); const command = new PutObjectCommand({ Bucket: cloud.bucket, Key: key, Body: fileContent, ContentType: 'application/octet-stream', }); await state.cloudClient.send(command); job.filesUploaded++; app.debug(`Synced to ${label}: ${key}`); } catch (err) { job.filesFailed++; job.errors.push(`${key}: ${err.message}`); } } job.status = 'completed'; job.phase = 'Complete'; job.progress = 100; job.currentFile = ''; setTimeout(() => cloudSyncJobs.delete(jobId), 5 * 60 * 1000); } catch (error) { job.status = 'error'; job.phase = error.message; } })(); return res.json({ success: true, jobId }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get cloud sync job status router.get('/api/cloud/sync/:jobId', async (req, res) => { const job = cloudSyncJobs.get(req.params.jobId); if (!job) { return res.status(404).json({ success: false, error: 'Job not found' }); } return res.json({ success: true, ...job }); }); // Web App Path Configuration API Routes (manages separate config file) // Get current path configurations router.get('/api/config/paths', (_, res) => { try { const webAppConfig = (0, commands_1.loadWebAppConfig)(app); return res.json({ success: true, paths: webAppConfig.paths, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Add new path configuration router.post('/api/config/paths', (req, res) => { try { const newPath = req.body; // Validate required fields if (!newPath.path) { res.status(400).json({ success: false, error: 'Path is required', }); return; } // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; // Add to current paths currentPaths.push(newPath); // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration added successfully', path: newPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Update existing path configuration router.put('/api/config/paths/:index', (req, res) => { try { const index = parseInt(req.params.index); const updatedPath = req.body; // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; if (index < 0 || index >= currentPaths.length) { res.status(404).json({ success: false, error: 'Path configuration not found', }); return; } // Validate required fields if (!updatedPath.path) { res.status(400).json({ success: false, error: 'Path is required', }); return; } // Update the path configuration currentPaths[index] = updatedPath; // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration updated successfully', path: updatedPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Remove path configuration router.delete('/api/config/paths/:index', (req, res) => { try { const index = parseInt(req.params.index); // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; if (index < 0 || index >= currentPaths.length) { res.status(404).json({ success: false, error: 'Path configuration not found', }); return; } // Get the path being removed for response const removedPath = currentPaths[index]; // Remove from current paths currentPaths.splice(index, 1); // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration removed successfully', removedPath: removedPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Command Management API endpoints // Get all registered commands router.get('/api/commands', (_, res) => { try { const commands = (0, commands_1.getCurrentCommands)(); return res.json({ success: true, commands: commands, count: commands.length, }); } catch (error) { app.error(`Error retrieving commands: ${error}`); return res.status(500).json({ success: false, error: 'Failed to retrieve commands', }); } }); // =========================================== // HOME PORT CONFIGURATION ENDPOINTS // =========================================== // Get home port configuration router.get('/api/config/homeport', (_req, res) => { try { if (!state.currentConfig) { return res.status(500).json({ success: false, error: 'Plugin configuration not available', }); } return res.json({ success: true, latitude: state.currentConfig.homePortLatitude || null, longitude: state.currentConfig.homePortLongitude || null, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Update home port configuration router.put('/api/config/homeport', (req, res) => { try { const { latitude, longitude } = req.body; if (!state.currentConfig) { res.status(500).json({ success: false, error: 'Plugin configuration not available', }); return; } // Validate latitude and longitude if (typeof latitude !== 'number' || typeof longitude !== 'number') { res.status(400).json({ success: false, error: 'Latitude and longitude must be numbers', }); return; } if (latitude < -90 || latitude > 90) { res.status(400).json({ success: false, error: 'Latitude must be between -90 and 90', }); return; } if (longitude < -180 || longitude > 180) { res.status(400).json({ success: false, error: 'Longitude must be between -180 and 180', }); return; } // Update the config state.currentConfig.homePortLatitude = latitude; state.currentConfig.homePortLongitude = longitude; // Update the cached config in commands module so thresholds use new home port (0, commands_1.updatePluginConfig)(state.currentConfig); // Save to plugin options app.savePluginOptions(state.currentConfig, (err) => { if (err) { app.error(`Failed to save home port: ${err}`); res.status(500).json({ success: false, error: 'Failed to save home port configuration', }); return; } app.debug(`✅ Home port updated: ${latitude}, ${longitude}`); res.json({ success: true, latitude, longitude, }); }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Get current vessel position router.get('/api/position/current', (_req, res) => { try { // Cast to any for compatibility with different @signalk/server-api versions const position = app.getSelfPath('navigation.position'); if (position && position.value) { return res.json({ success: true, latitude: position.value.latitude, longitude: position.value.longitude, }); } return res.status(404).json({ success: false, error: 'Current position not available', }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // =========================================== // PATH TYPE DETECTION ENDPOINT // =========================================== // Get data type information for a SignalK path router.get('/api/paths/:path/type', async (req, res) => { try { const pathParam = req.params.path; const { detectPathType } = await Promise.resolve().then(() => __importStar(require('./utils/type-detector'))); const typeInfo = await detectPathType(pathParam, app); return res.json({ success: true, ...typeInfo, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // =========================================== // COMMAND MANAGEMENT ENDPOINTS // =========================================== // Register a new command router.post('/api/commands', (req, res) => { try { const { command, description, keywords, defaultState, thresholds } = req.body; if (!command || !/^[a-zA-Z0-9_]+$/.test(command) || command.length === 0 || command.length > 50) { return res.status(400).json({ success: false, error: 'Invalid command name. Must be alphanumeric with underscores, 1-50 characters.', }); } const result = (0, commands_1.registerCommand)(command, description, keywords, defaultState, thresholds); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); const commandState = (0, commands_1.getCommandState)(); const commandConfig = commandState.registeredCommands.get(command); return res.json({ success: true, message: `Command '${command}' registered successfully`, command: commandConfig, }); } else { return res.status(400).json({ success: false, error: result.message || 'Failed to register command', }); } } catch (error) { app.error(`Error registering command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Execute a command router.put('/api/commands/:command/execute', (req, res) => { try { const { command } = req.params; const { value } = req.body; if (typeof value !== 'boolean') { return res.status(400).json({ success: false, error: 'Command value must be a boolean', }); } const result = (0, commands_1.executeCommand)(command, value); if (result.state === 'COMPLETED') { return res.json({ success: true, command: command, value: value, executed: true, timestamp: result.timestamp, }); } else { return res.status(400).json({ success: false, error: result.message || 'Failed to execute command', }); } } catch (error) { app.error(`Error executing command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Unregister a command router.delete('/api/commands/:command', (req, res) => { try { const { command } = req.params; const result = (0, commands_1.unregisterCommand)(command); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: `Command '${command}' unregistered successfully`, }); } else { return res.status(404).json({ success: false, error: result.message || 'Command not found', }); } } catch (error) { app.error(`Error unregistering command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Update command (PUT) router.put('/api/commands/:command', (req, res) => { try { const { command } = req.params; const { description, keywords, defaultState, thresholds } = req.body; const result = (0, commands_1.updateCommand)(command, description, keywords, defaultState, thresholds); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: result.message, }); } else { return res.status(result.statusCode || 400).json({ success: false, error: result.message, }); } } catch (error) { app.error(`Error updating command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Manual override endpoint router.put('/api/commands/:command/override', (req, res) => { try { const { command } = req.params; const { override, expiryMinutes } = req.body; const result = (0, commands_1.setManualOverride)(command, override, expiryMinutes); if (result.success) { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: result.message, }); } else { return res