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
JavaScript
"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