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,681 lines (1,490 loc) • 102 kB
text/typescript
import * as fs from 'fs-extra';
import * as path from 'path';
import express, { Router } from 'express';
import { getAvailablePaths } from './utils/path-discovery';
import { DuckDBInstance } from '@duckdb/node-api';
import {
TypedRequest,
TypedResponse,
PathsApiResponse,
FilesApiResponse,
QueryApiResponse,
SampleApiResponse,
ConfigApiResponse,
HealthApiResponse,
S3TestApiResponse,
QueryRequest,
PathConfigRequest,
PathInfo,
CommandApiResponse,
CommandRegistrationRequest,
CommandExecutionRequest,
CommandExecutionResponse,
PluginState,
PluginConfig,
PathConfig,
AnalysisApiResponse,
ClaudeConnectionTestResponse,
ValidationApiResponse,
ValidationViolation,
} from './types';
import { SchemaService } from './schema-service';
import { ProcessType, ProcessState, ProcessStatusApiResponse, ProcessCancelApiResponse } from './types';
import {
loadWebAppConfig,
saveWebAppConfig,
registerCommand,
updateCommand,
unregisterCommand,
executeCommand,
getCurrentCommands,
getCommandHistory,
getCommandState,
setManualOverride,
} from './commands';
import { updateDataSubscriptions } from './data-handler';
import { toContextFilePath, toParquetFilePath } from './utils/path-helpers';
import { ServerAPI, Context } from '@signalk/server-api';
import { ClaudeAnalyzer, AnalysisRequest, FollowUpRequest } from './claude-analyzer';
import { AnalysisTemplateManager, TEMPLATE_CATEGORIES } from './analysis-templates';
import { VesselContextManager } from './vessel-context';
// import { initializeStreamingService, shutdownStreamingService } from './index';
// Progress tracking for validation jobs
interface ValidationProgress {
jobId: string;
status: 'running' | 'cancelling' | 'completed' | 'cancelled' | 'error';
processed: number;
total: number;
percent: number;
startTime: Date;
currentFile?: string;
currentVessel?: string;
currentRelativePath?: string;
cancelRequested?: boolean;
error?: string;
completedAt?: Date;
result?: ValidationApiResponse;
}
const validationJobs = new Map<string, ValidationProgress>();
let lastValidationViolations: ValidationViolation[] = [];
interface RepairProgress {
jobId: string;
status: 'running' | 'cancelling' | 'completed' | 'cancelled' | 'error';
processed: number;
total: number;
percent: number;
startTime: Date;
currentFile?: string;
message?: string;
cancelRequested?: boolean;
completedAt?: Date;
result?: {
success: boolean;
repairedFiles: number;
backedUpFiles: number;
skippedFiles: string[];
quarantinedFiles: string[];
errors: string[];
message?: string;
};
}
const repairJobs = new Map<string, RepairProgress>();
const VALIDATION_JOB_TTL_MS = 10 * 60 * 1000; // Retain job metadata for 10 minutes
function scheduleValidationJobCleanup(jobId: string) {
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: string) {
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: ClaudeAnalyzer | null = null;
/**
* Get or create the shared Claude analyzer instance
*/
function getSharedAnalyzer(config: any, app: ServerAPI, getDataDir: () => string, state: PluginState): ClaudeAnalyzer {
if (!sharedAnalyzer) {
sharedAnalyzer = new ClaudeAnalyzer({
apiKey: config.claudeIntegration.apiKey,
model: migrateClaudeModel(config.claudeIntegration.model, app) as any,
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: any;
import { getValidClaudeModel } from './claude-models';
// Helper function to migrate deprecated Claude model names
function migrateClaudeModel(model?: string, app?: ServerAPI): string {
const validatedModel = getValidClaudeModel(model);
if (model && validatedModel !== model) {
app?.debug(`Auto-migrated Claude model ${model} to ${validatedModel}`);
}
return validatedModel;
}
// ===========================================
// PROCESS MANAGEMENT UTILITIES
// ===========================================
function startProcess(state: PluginState, type: ProcessType, totalFiles?: number): boolean {
// 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: PluginState, processedFiles: number, currentFile?: string): void {
if (state.currentProcess?.isRunning) {
state.currentProcess.processedFiles = processedFiles;
state.currentProcess.currentFile = currentFile;
}
}
function finishProcess(state: PluginState): void {
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: PluginState): boolean {
if (state.currentProcess?.isRunning) {
state.currentProcess.cancelRequested = true;
state.currentProcess.abortController?.abort();
return true;
}
return false;
}
function getProcessStatus(state: PluginState): ProcessStatusApiResponse {
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
};
}
export function registerApiRoutes(
router: Router,
state: PluginState,
app: ServerAPI
): void {
// Serve static files from public directory
const publicPath = path.join(__dirname, '../public');
if (fs.existsSync(publicPath)) {
router.use(express.static(publicPath));
}
// Get the current configuration for data directory
const getDataDir = (): string => {
// 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: any[]): any[] {
return rawData.map(row => {
const convertedRow: typeof row = {};
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',
(_: TypedRequest, res: TypedResponse<PathsApiResponse>) => {
try {
const dataDir = getDataDir();
const paths = getAvailablePaths(dataDir, app);
return res.json({
success: true,
dataDirectory: dataDir,
paths: paths,
});
} catch (error) {
return res.status(500).json({
success: false,
error: (error as Error).message,
});
}
}
);
// Get files for a specific path
router.get(
'/api/files/:path(*)',
(req: TypedRequest, res: TypedResponse<FilesApiResponse>) => {
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: string) => file.endsWith('.parquet'))
.map((file: string) => {
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 as Error).message,
});
}
}
);
// Get sample data from a specific file
router.get(
'/api/sample/:path(*)',
async (req: TypedRequest, res: TypedResponse<SampleApiResponse>) => {
try {
const dataDir = getDataDir();
const signalkPath = req.params.path;
const limit = parseInt(req.query.limit as string) || 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: string) => file.endsWith('.parquet'))
.map((file: string) => {
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 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 as Error).message,
});
} finally {
connection.disconnectSync();
}
} catch (error) {
return res.status(500).json({
success: false,
error: (error as Error).message,
});
}
}
);
// Query parquet data
router.post(
'/api/query',
async (
req: TypedRequest<QueryRequest>,
res: TypedResponse<QueryApiResponse>
) => {
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 = toContextFilePath(
app.selfContext as Context
);
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 = toParquetFilePath(
dataDir,
selfContextPath,
quotedPath
);
processedQuery = processedQuery.replace(match, `'${filePath}'`);
}
});
}
const instance = await 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 as Error).message,
});
} finally {
connection.disconnectSync();
}
} catch (error) {
return res.status(500).json({
success: false,
error: (error as Error).message,
});
}
}
);
// Test S3 connection
router.post(
'/api/test-s3',
async (_: TypedRequest, res: TypedResponse<S3TestApiResponse>) => {
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 import('@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 as Error).message || 'S3 connection failed',
});
}
}
);
// Web App Path Configuration API Routes (manages separate config file)
// Get current path configurations
router.get(
'/api/config/paths',
(_: TypedRequest, res: TypedResponse<ConfigApiResponse>) => {
try {
const webAppConfig = loadWebAppConfig(app);
return res.json({
success: true,
paths: webAppConfig.paths,
});
} catch (error) {
return res.status(500).json({
success: false,
error: (error as Error).message,
});
}
}
);
// Add new path configuration
router.post(
'/api/config/paths',
(req: TypedRequest<PathConfigRequest>, res: TypedResponse): void => {
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 = loadWebAppConfig(app);
const currentPaths = webAppConfig.paths;
const currentCommands = webAppConfig.commands;
// Add to current paths
currentPaths.push(newPath);
// Save to web app configuration
saveWebAppConfig(currentPaths, currentCommands, app);
// Update subscriptions
if (state.currentConfig) {
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 as Error).message,
});
}
}
);
// Update existing path configuration
router.put(
'/api/config/paths/:index',
(req: TypedRequest<PathConfigRequest>, res: TypedResponse): void => {
try {
const index = parseInt(req.params.index);
const updatedPath = req.body;
// Load current configuration
const webAppConfig = 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
saveWebAppConfig(currentPaths, currentCommands, app);
// Update subscriptions
if (state.currentConfig) {
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 as Error).message,
});
}
}
);
// Remove path configuration
router.delete(
'/api/config/paths/:index',
(req: TypedRequest, res: TypedResponse): void => {
try {
const index = parseInt(req.params.index);
// Load current configuration
const webAppConfig = 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
saveWebAppConfig(currentPaths, currentCommands, app);
// Update subscriptions
if (state.currentConfig) {
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 as Error).message,
});
}
}
);
// Command Management API endpoints
// Get all registered commands
router.get(
'/api/commands',
(_: TypedRequest, res: TypedResponse<CommandApiResponse>) => {
try {
const commands = 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 as Error).message,
});
}
});
// Update home port configuration
router.put('/api/config/homeport', (req, res): void => {
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?: unknown) => {
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 as 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 as 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 import('./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 as Error).message,
});
}
});
// ===========================================
// COMMAND MANAGEMENT ENDPOINTS
// ===========================================
// Register a new command
router.post(
'/api/commands',
(
req: TypedRequest<CommandRegistrationRequest>,
res: TypedResponse<CommandApiResponse>
) => {
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 = registerCommand(command, description, keywords, defaultState, thresholds);
if (result.state === 'COMPLETED') {
// Update webapp config
const webAppConfig = loadWebAppConfig(app);
const currentCommands = getCurrentCommands();
saveWebAppConfig(webAppConfig.paths, currentCommands, app);
const commandState = 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: TypedRequest<CommandExecutionRequest>,
res: TypedResponse<CommandExecutionResponse>
) => {
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 = 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: TypedRequest, res: TypedResponse<CommandApiResponse>) => {
try {
const { command } = req.params;
const result = unregisterCommand(command);
if (result.state === 'COMPLETED') {
// Update webapp config
const webAppConfig = loadWebAppConfig(app);
const currentCommands = getCurrentCommands();
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: TypedRequest<{
description?: string;
keywords?: string[];
defaultState?: boolean;
thresholds?: any[];
}>,
res: TypedResponse<CommandApiResponse>
) => {
try {
const { command } = req.params;
const { description, keywords, defaultState, thresholds } = req.body;
const result = updateCommand(command, description, keywords, defaultState, thresholds);
if (result.state === 'COMPLETED') {
// Update webapp config
const webAppConfig = loadWebAppConfig(app);
const currentCommands = getCurrentCommands();
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: TypedRequest<{ override: boolean; expiryMinutes?: number }>,
res: TypedResponse<CommandApiResponse>
) => {
try {
const { command } = req.params;
const { override, expiryMinutes } = req.body;
const result = setManualOverride(command, override, expiryMinutes);
if (result.success) {
// Update webapp config
const webAppConfig = loadWebAppConfig(app);
const currentCommands = getCurrentCommands();
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',
(_: TypedRequest, res: TypedResponse<CommandApiResponse>) => {
try {
// Return the last 50 history entries
const commandHistory = 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: TypedRequest, res: TypedResponse<CommandApiResponse>) => {
try {
const { command } = req.params;
const commandState = 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',
(_: TypedRequest, res: TypedResponse<HealthApiResponse>) => {
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',
(_: TypedRequest, res: TypedResponse<AnalysisApiResponse>) => {
try {
const templates = 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 as any
});
} catch (error) {
app.error(`Template retrieval failed: ${(error as 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: TypedRequest, res: TypedResponse<ClaudeConnectionTestResponse>) => {
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 as Error).message}`);
return res.status(500).json({
success: false,
error: 'Claude connection test failed'
});
}
}
);
// Main analysis endpoint
router.post(
'/api/analyze',
async (req: TypedRequest<{
dataPath: string;
analysisType?: string;
templateId?: string;
customPrompt?: string;
timeRange?: { start: string; end: string };
aggregationMethod?: string;
resolution?: string;
claudeModel?: string;
useDatabaseAccess?: boolean;
}>, res: TypedResponse<AnalysisApiResponse>) => {
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: AnalysisRequest;
if (templateId) {
// Use template
const parsedTimeRange = timeRange ? {
start: new Date(timeRange.start),
end: new Date(timeRange.end)
} : undefined;
const templateRequest = 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 as any) || '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 as Error).message}`);
return res.status(500).json({
success: false,
error: `Analysis failed: ${(error as Error).message}`
});
}
}
);
// Follow-up question endpoint
router.post(
'/api/analyze/followup',
async (req: TypedRequest<{ conversationId: string; question: string }>, res: TypedResponse<AnalysisApiResponse>) => {
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 as Error).message}`);
return res.status(500).json({
success: false,
error: `Follow-up question failed: ${(error as Error).message}`
});
}
}
);
// Get analysis history
router.get(
'/api/analyze/history',
async (req: TypedRequest<any> & { query: { limit?: string } }, res: TypedResponse<AnalysisApiResponse>) => {
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,
severity: a.severity,
description: a.description,
confidence: a.confidence
})),
confidence: h.confidence,
dataQuality: h.dataQuality,
timestamp: h.timestamp,
metadata: h.metadata
}))
});
} catch (error) {
app.error(`Analysis history retrieval failed: ${(error as Error).message}`);
return res.status(500).json({
success: false,
error: 'Failed to retrieve analysis history'
});
}
}
);
// Delete analysis from history
router.delete(
'/api/analyze/history/:id',
async (req: TypedRequest<any> & { params: { id: string } }, res: TypedResponse<any>) => {
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 analysisId = req.params.id;
const analyzer = getSharedAnalyzer(config, app, getDataDir, state);
const result = await analyzer.deleteAnalysis(analysisId);
if (result.success) {
return res.json({
success: true,
message: 'Analysis deleted successfully'
});
} else {
return res.status(404).json({
success: false,
error: result.error
});
}
} catch (error) {
app.error(`Analysis deletion failed: ${(error as Error).message}`);
return res.status(500).json({
success: false,
error: 'Failed to delete analysis'
});
}
}
);
// ===========================================
// VESSEL CONTEXT API ROUTES
// ===========================================
// Get vessel context
router.get(
'/api/vessel-context',
async (_: TypedRequest, res: TypedResponse<any>) => {
try {
const contextManager = new VesselContextManager(app, getDataDir());
const context = await contextManager.getVesselContext();
return res.json({
success: true,
data: context
});
} catch (error) {
app.error(`Failed to get vessel context: ${(error as Error).message}`);
return res.status(500).json({
success: false,
error: 'Failed to get vessel context'
});
}
}
);
// Update vessel context
router.post(
'/api/vessel-context',
async (req: TypedRequest<{
vesselInfo?: any;
customContext?: string;
}>, res: TypedResponse<any>) => {
try {
const { vesselInfo, customContext } =