signalk-parquet
Version:
SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.
1,276 lines • 112 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 express_1 = __importDefault(require("express"));
const path_discovery_1 = require("./utils/path-discovery");
const node_api_1 = require("@duckdb/node-api");
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, getDataDir, 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, getDataDir(), 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));
}
// Get the current configuration for data directory
const getDataDir = () => {
// Use the user-configured output directory, fallback to SignalK default
return state.currentConfig?.outputDirectory || app.getDataDirPath();
};
// 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;
});
}
// Get available SignalK paths
router.get('/api/paths', (_, res) => {
try {
const dataDir = getDataDir();
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 dataDir = getDataDir();
const signalkPath = req.params.path;
const selfContextPath = app.selfContext
.replace(/\./g, '/')
.replace(/:/g, '_');
const pathDir = path.join(dataDir, selfContextPath, signalkPath.replace(/\./g, '/'));
if (!fs.existsSync(pathDir)) {
return res.status(404).json({
success: false,
error: `Path not found: ${signalkPath}`,
});
}
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) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
return res.json({
success: true,
path: signalkPath,
directory: pathDir,
files: 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 dataDir = getDataDir();
const signalkPath = req.params.path;
const limit = parseInt(req.query.limit) || 10;
const selfContextPath = app.selfContext
.replace(/\./g, '/')
.replace(/:/g, '_');
const pathDir = path.join(dataDir, selfContextPath, signalkPath.replace(/\./g, '/'));
if (!fs.existsSync(pathDir)) {
return res.status(404).json({
success: false,
error: `Path not found: ${signalkPath}`,
});
}
// Get the most recent parquet file
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, modified: stat.mtime };
})
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
if (files.length === 0) {
return res.status(404).json({
success: false,
error: `No parquet files found for path: ${signalkPath}`,
});
}
const sampleFile = files[0];
const query = `SELECT * FROM read_parquet('${sampleFile.path}', union_by_name=true) LIMIT ${limit}`;
const instance = await node_api_1.DuckDBInstance.create();
const connection = await instance.connect();
// Load spatial extension for geographic queries
await connection.runAndReadAll("INSTALL spatial;");
await connection.runAndReadAll("LOAD spatial;");
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
router.post('/api/query', async (req, res) => {
try {
const { query } = req.body;
if (!query) {
return res.status(400).json({
success: false,
error: 'Query is required',
});
}
const dataDir = getDataDir();
// 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}'`);
}
});
}
const instance = await node_api_1.DuckDBInstance.create();
const connection = await instance.connect();
// Load spatial extension for geographic queries
await connection.runAndReadAll("INSTALL spatial;");
await connection.runAndReadAll("LOAD spatial;");
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,
});
}
});
// Test S3 connection
router.post('/api/test-s3', async (_, res) => {
try {
if (!state.currentConfig) {
return res.status(500).json({
success: false,
error: 'Plugin not started or configuration not available',
});
}
if (!state.currentConfig.s3Upload.enabled) {
return res.status(400).json({
success: false,
error: 'S3 upload is not enabled in configuration',
});
}
if (!ListObjectsV2Command || !state.s3Client) {
// Try to import S3 SDK dynamically
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: 'S3 client not available or not initialized',
});
}
}
// Test S3 connection by listing bucket
const listCommand = new ListObjectsV2Command({
Bucket: state.currentConfig.s3Upload.bucket,
MaxKeys: 1,
});
await state.s3Client.send(listCommand);
return res.json({
success: true,
message: 'S3 connection successful',
bucket: state.currentConfig.s3Upload.bucket,
region: state.currentConfig.s3Upload.region || 'us-east-1',
keyPrefix: state.currentConfig.s3Upload.keyPrefix || 'none',
});
}
catch (error) {
return res.status(500).json({
success: false,
error: error.message || 'S3 connection failed',
});
}
});
// 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;
// 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 {
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.status(400).json({
success: false,
error: result.message,
});
}
}
catch (error) {
app.error(`Error setting manual override: ${error}`);
return res.status(500).json({
success: false,
error: 'Internal server error',
});
}
});
// Get command history
router.get('/api/commands/history', (_, res) => {
try {
// Return the last 50 history entries
const commandHistory = (0, commands_1.getCommandHistory)();
const recentHistory = commandHistory.slice(-50);
return res.json({
success: true,
data: recentHistory,
});
}
catch (error) {
app.error(`Error retrieving command history: ${error}`);
return res.status(500).json({
success: false,
error: 'Failed to retrieve command history',
});
}
});
// Get command status
router.get('/api/commands/:command/status', (req, res) => {
try {
const { command } = req.params;
const commandState = (0, commands_1.getCommandState)();
const commandConfig = commandState.registeredCommands.get(command);
if (!commandConfig) {
return res.status(404).json({
success: false,
error: 'Command not found',
});
}
// Get current value from SignalK
const currentValue = app.getSelfPath(`commands.${command}`);
return res.json({
success: true,
command: {
...commandConfig,
active: currentValue === true,
},
});
}
catch (error) {
app.error(`Error retrieving command status: ${error}`);
return res.status(500).json({
success: false,
error: 'Failed to retrieve command status',
});
}
});
// Streaming Control API endpoints - DISABLED
// // Enable streaming at runtime
// router.post('/api/streaming/enable', async (req: TypedRequest, res: TypedResponse) => {
// try {
// if (state.streamingService) {
// return res.json({
// success: true,
// message: 'Streaming service is already running',
// enabled: true
// });
// }
// // Check if streaming is enabled in config
// if (!state.currentConfig?.enableStreaming) {
// return res.status(400).json({
// success: false,
// error: 'Streaming is disabled in plugin configuration. Enable it in plugin settings first.',
// enabled: false
// });
// }
// const result = await initializeStreamingService(state, app);
// if (result.success) {
// return res.json({
// success: true,
// message: 'Streaming service enabled successfully',
// enabled: true
// });
// } else {
// return res.status(500).json({
// success: false,
// error: result.error || 'Failed to enable streaming service',
// enabled: false
// });
// }
// } catch (error) {
// app.error(`Error enabling streaming: ${error}`);
// return res.status(500).json({
// success: false,
// error: (error as Error).message,
// enabled: false
// });
// }
// });
// // Disable streaming at runtime
// router.post('/api/streaming/disable', (req: TypedRequest, res: TypedResponse) => {
// try {
// if (!state.streamingService) {
// return res.json({
// success: true,
// message: 'Streaming service is not running',
// enabled: false
// });
// }
// const result = shutdownStreamingService(state, app);
// if (result.success) {
// return res.json({
// success: true,
// message: 'Streaming service disabled successfully',
// enabled: false
// });
// } else {
// return res.status(500).json({
// success: false,
// error: result.error || 'Failed to disable streaming service',
// enabled: true
// });
// }
// } catch (error) {
// app.error(`Error disabling streaming: ${error}`);
// return res.status(500).json({
// success: false,
// error: (error as Error).message,
// enabled: true
// });
// }
// });
// // Get current streaming status
// router.get('/api/streaming/status', (req: TypedRequest, res: TypedResponse) => {
// try {
// const isEnabled = !!state.streamingService;
// const configEnabled = state.currentConfig?.enableStreaming ?? false;
// // Get streaming service statistics if available
// let stats = {};
// if (state.streamingService && state.streamingService.getActiveSubscriptions) {
// const subscriptions = state.streamingService.getActiveSubscriptions();
// stats = {
// activeSubscriptions: subscriptions.length,
// subscriptions: subscriptions
// };
// }
// res.json({
// success: true,
// enabled: isEnabled,
// configEnabled: configEnabled,
// canEnable: configEnabled && !isEnabled,
// canDisable: isEnabled,
// ...stats
// });
// } catch (error) {
// app.error(`Error getting streaming status: ${error}`);
// res.status(500).json({
// success: false,
// error: (error as Error).message
// });
// }
// });
// Health check
router.get('/api/health', (_, res) => {
return res.json({
success: true,
status: 'healthy',
timestamp: new Date().toISOString(),
});
});
// ===========================================
// CLAUDE AI ANALYSIS API ROUTES
// ===========================================
// Get available analysis templates
router.get('/api/analyze/templates', (_, res) => {
try {
const templates = analysis_templates_1.TEMPLATE_CATEGORIES.map(category => ({
...category,
templates: category.templates.map(template => ({
id: template.id,
name: template.name,
description: template.description,
category: template.category,
icon: template.icon,
complexity: template.complexity,
estimatedTime: template.estimatedTime,
requiredPaths: template.requiredPaths
}))
}));
res.json({
success: true,
templates: templates
});
}
catch (error) {
app.error(`Template retrieval failed: ${error.message}`);
res.status(500).json({
success: false,
error: 'Failed to retrieve analysis templates'
});
}
});
// Test Claude connection
router.post('/api/analyze/test-connection', async (_req, res) => {
try {
const config = state.currentConfig;
if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) {
return res.status(400).json({
success: false,
error: 'Claude integration is not configured or enabled'
});
}
const analyzer = getSharedAnalyzer(config, app, getDataDir, state);
const startTime = Date.now();
const testResult = await analyzer.testConnection();
const responseTime = Date.now() - startTime;
if (testResult.success) {
return res.json({
success: true,
model: migrateClaudeModel(config.claudeIntegration.model, app),
responseTime,
tokenUsage: 50 // Approximate for test
});
}
else {
return res.status(400).json({
success: false,
error: testResult.error || 'Connection test failed'
});
}
}
catch (error) {
app.error(`Claude connection test failed: ${error.message}`);
return res.status(500).json({
success: false,
error: 'Claude connection test failed'
});
}
});
// Main analysis endpoint
router.post('/api/analyze', async (req, res) => {
try {
const config = state.currentConfig;
if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) {
return res.status(400).json({
success: false,
error: 'Claude integration is not configured or enabled'
});
}
const { dataPath, analysisType, templateId, customPrompt, timeRange, aggregationMethod, resolution, claudeModel, useDatabaseAccess } = req.body;
console.log(`🧠 ANALYSIS REQUEST: dataPath=${dataPath}, templateId=${templateId}, analysisType=${analysisType}, aggregationMethod=${aggregationMethod}, model=${claudeModel || 'config-default'}`);
console.log(`🔍 CUSTOM PROMPT DEBUG: "${customPrompt}" (type: ${typeof customPrompt}, length: ${customPrompt?.length || 0})`);
if (!dataPath) {
return res.status(400).json({
success: false,
error: 'Data path is required'
});
}
// Use shared analyzer instance to maintain conversation state
const analyzer = getSharedAnalyzer(config, app, getDataDir, state);
// Build analysis request
let analysisRequest;
if (templateId) {
// Use template
const parsedTimeRange = timeRange ? {
start: new Date(timeRange.start),
end: new Date(timeRange.end)
} : undefined;
const templateRequest = analysis_templates_1.AnalysisTemplateManager.createAnalysisRequest(templateId, dataPath, customPrompt, parsedTimeRange);
if (!templateRequest) {
return res.status(400).json({
success: false,
error: `Template not found: ${templateId}`
});
}
analysisRequest = templateRequest;
}
else {
// Custom analysis
analysisRequest = {
dataPath,
analysisType: analysisType || 'custom',
customPrompt: customPrompt || 'Analyze this maritime data and provide insights',
timeRange: timeRange ? {
start: new Date(timeRange.start),
end: new Date(timeRange.end)
} : undefined,
aggregationMethod,
resolution,
useDatabaseAccess: useDatabaseAccess || false
};
}
// Execute analysis
app.debug(`Starting Claude analysis: ${analysisRequest.analysisType} for ${dataPath}`);
const result = await analyzer.analyzeData(analysisRequest);
return res.json({
success: true,
data: {
id: result.id,
analysis: result.analysis,
insights: result.insights,
recommendations: result.recommendations,
anomalies: result.anomalies?.map(a => ({
timestamp: a.timestamp,
value: a.value,
expectedRange: a.expectedRange,
severity: a.severity,
description: a.description,
confidence: a.confidence
})),
confidence: result.confidence,
dataQuality: result.dataQuality,
timestamp: result.timestamp,
metadata: result.metadata
},
usage: result.usage
});
}
catch (error) {
app.error(`Analysis failed: ${error.message}`);
return res.status(500).json({
success: false,
error: `Analysis failed: ${error.message}`
});
}
});
// Follow-up question endpoint
router.post('/api/analyze/followup', async (req, res) => {
try {
const config = state.currentConfig;
if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) {
return res.status(400).json({
success: false,
error: 'Claude integration is not configured or enabled'
});
}
const { conversationId, question } = req.body;
if (!conversationId || !question) {
return res.status(400).json({
success: false,
error: 'Both conversationId and question are required'
});
}
console.log(`🔄 FOLLOW-UP REQUEST: conversationId=${conversationId}, question=${question.substring(0, 100)}...`);
// Use shared analyzer instance to access stored conversations
const analyzer = getSharedAnalyzer(config, app, getDataDir, state);
// Process follow-up question
const followUpRequest = {
conversationId,
question
};
const analysisResult = await analyzer.askFollowUp(followUpRequest);
return res.json({
success: true,
data: analysisResult,
usage: analysisResult.usage
});
}
catch (error) {
app.error(`Follow-up question failed: ${error.message}`);
return res.status(500).json({
success: false,
error: `Follow-up question failed: ${error.message}`
});
}
});
// Get analysis history
router.get('/api/analyze/history', async (req, res) => {
try {
const config = state.currentConfig;
if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) {
return res.status(400).json({
success: false,
error: 'Claude integration is not configured or enabled'
});
}
const limit = parseInt(req.query.limit || '20', 10);
const analyzer = getSharedAnalyzer(config, app, getDataDir, state);
const history = await analyzer.getAnalysisHistory(limit);
return res.json({
success: true,
data: history.map(h => ({
id: h.id,
analysis: h.analysis,
insights: h.insights,
recommendations: h.recommendations,
anomalies: h.anomalies?.map(a => ({
timestamp: a.timestamp,
value: a.value,
expectedRange: a.expectedRange,
sev