node-red-contrib-code-analyzer
Version:
A Node-RED package that provides a background service to detect debugging artifacts in function nodes across Node-RED flows. Features performance monitoring (CPU, memory, event loop), queue monitoring, and Slack alerting.
1,118 lines (996 loc) • 47.9 kB
JavaScript
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
class PerformanceDatabase {
constructor(dbPath = null) {
this.db = null;
this.dbPath = dbPath || this.getDefaultDbPath();
this.retentionDays = 7; // Default retention period
this.initialized = false;
this.operationQueue = [];
this.isProcessingQueue = false;
this.creationMessages = [];
// Don't automatically initialize database - wait for explicit creation
}
// Get default database path with fallback options
getDefaultDbPath() {
const packagePath = path.join(__dirname, '..', 'performance_metrics.db');
return packagePath;
}
// Detect if running in Docker environment using reliable is-docker package
isDockerEnvironment() {
try {
const isDocker = require('is-docker');
return isDocker();
} catch (error) {
// Fallback to manual detection if is-docker package fails
// eslint-disable-next-line no-console
console.warn('is-docker package failed, using fallback detection:', error.message);
return !!(
this.pathExists('/.dockerenv') ||
process.env.DOCKER_CONTAINER ||
process.env.container === 'docker'
);
}
}
// Check if path exists (helper method)
pathExists(filePath) {
try {
const fs = require('fs');
return fs.existsSync(filePath);
} catch {
return false;
}
}
// Get fallback paths for database creation with Docker awareness
getFallbackPaths() {
const os = require('os');
const packagePath = path.join(__dirname, '..', 'performance_metrics.db');
const isDocker = this.isDockerEnvironment();
if (isDocker) {
// Docker-optimized fallback strategy
return [
{
path: '/data/performance_metrics.db',
description: 'Node-RED data volume (persistent)',
persistent: true,
priority: 'recommended'
},
{
path: '/data/.node-red/performance_metrics.db',
description: 'Node-RED data subdirectory (persistent)',
persistent: true,
priority: 'good'
},
{
path: packagePath,
description: 'package root folder (temporary)',
persistent: false,
priority: 'warning'
},
{
path: path.join(os.tmpdir(), 'node-red-performance_metrics.db'),
description: 'system temporary directory (temporary)',
persistent: false,
priority: 'warning'
}
];
} else {
// Standard fallback strategy for non-Docker environments
return [
{
path: packagePath,
description: 'package root folder',
persistent: true,
priority: 'recommended'
},
{
path: path.join(process.env.HOME || process.env.USERPROFILE || os.tmpdir(), '.node-red', 'performance_metrics.db'),
description: 'user Node-RED directory',
persistent: true,
priority: 'good'
},
{
path: path.join(os.tmpdir(), 'node-red-performance_metrics.db'),
description: 'system temporary directory',
persistent: true,
priority: 'fallback'
}
];
}
}
// Check if database exists
databaseExists() {
const fs = require('fs');
return fs.existsSync(this.dbPath);
}
// Initialize SQLite database with proper error handling
initDatabase() {
return new Promise((resolve, reject) => {
try {
// Use callback constructor to catch connection errors
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
this.initialized = false;
reject(err);
return;
}
// Database opened successfully, configure it
this.db.configure('busyTimeout', 30000);
// Run pragma statements with error handling
this.db.serialize(() => {
let completedOperations = 0;
const totalOperations = 10; // Number of CREATE operations below
let hasError = false;
const checkCompletion = (err) => {
if (err && !hasError) {
hasError = true;
this.initialized = false;
reject(err);
return;
}
completedOperations++;
if (completedOperations === totalOperations && !hasError) {
this.initialized = true;
resolve();
}
};
// Configure database for better concurrency
this.db.run('PRAGMA journal_mode = WAL', checkCompletion);
this.db.run('PRAGMA synchronous = NORMAL', checkCompletion);
this.db.run('PRAGMA cache_size = 1000', checkCompletion);
this.db.run('PRAGMA temp_store = MEMORY', checkCompletion);
// Create tables with error handling
this.db.run(`
CREATE TABLE IF NOT EXISTS performance_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cpu_usage REAL NOT NULL,
memory_usage REAL NOT NULL,
memory_rss INTEGER NOT NULL,
event_loop_lag REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, checkCompletion);
this.db.run(`
CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metric_type TEXT NOT NULL,
threshold_value REAL NOT NULL,
current_value REAL NOT NULL,
duration_minutes REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, checkCompletion);
this.db.run(`
CREATE TABLE IF NOT EXISTS code_quality_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flow_id TEXT NOT NULL,
flow_name TEXT,
total_issues INTEGER NOT NULL DEFAULT 0,
nodes_with_issues INTEGER NOT NULL DEFAULT 0,
nodes_with_critical_issues INTEGER NOT NULL DEFAULT 0,
total_function_nodes INTEGER NOT NULL DEFAULT 0,
issue_types TEXT,
quality_score REAL NOT NULL DEFAULT 100,
complexity_score REAL NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, checkCompletion);
this.db.run(`
CREATE TABLE IF NOT EXISTS node_quality_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flow_id TEXT NOT NULL,
node_id TEXT NOT NULL,
node_name TEXT,
node_type TEXT DEFAULT 'function',
issues_count INTEGER NOT NULL DEFAULT 0,
issue_details TEXT,
complexity_score REAL NOT NULL DEFAULT 0,
lines_of_code INTEGER DEFAULT 0,
quality_score REAL NOT NULL DEFAULT 100,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, checkCompletion);
// Create indexes
this.db.run('CREATE INDEX IF NOT EXISTS idx_created_at_metrics ON performance_metrics(created_at)', checkCompletion);
this.db.run('CREATE INDEX IF NOT EXISTS idx_code_quality_created_at ON code_quality_metrics(created_at)', checkCompletion);
// Migration: Add nodes_with_critical_issues column (ignore errors for this one)
this.db.run(`
ALTER TABLE code_quality_metrics
ADD COLUMN nodes_with_critical_issues INTEGER NOT NULL DEFAULT 0
`, () => {
// This operation doesn't count towards completion as it might fail (column exists)
// Just continue
});
});
});
} catch (error) {
this.initialized = false;
reject(error);
}
});
}
// Create database and initialize tables with fallback paths (manual creation)
async createDatabase(progressCallback = null) {
const fallbackPaths = this.getFallbackPaths();
this.creationMessages = [];
for (let i = 0; i < fallbackPaths.length; i++) {
const fallback = fallbackPaths[i];
const attemptMessage = `Trying to create database in ${fallback.description} (${fallback.path})...`;
this.creationMessages.push({
type: 'info',
message: attemptMessage
});
// Send progress update if callback provided
if (progressCallback) {
progressCallback({
attempt: i + 1,
total: fallbackPaths.length,
location: fallback.description,
path: fallback.path,
messages: [...this.creationMessages]
});
}
try {
// Ensure directory exists for this path
const fs = require('fs');
const dbDir = path.dirname(fallback.path);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Set the current path and try to create database
this.dbPath = fallback.path;
await this.initDatabase();
// Success!
const successMessage = `✓ Database created successfully in ${fallback.description}`;
this.creationMessages.push({
type: 'success',
message: successMessage
});
// Add persistence warning for Docker temporary locations
if (this.isDockerEnvironment() && !fallback.persistent) {
const warningMessage = '⚠️ WARNING: Database is in temporary storage - will be lost when container restarts!';
this.creationMessages.push({
type: 'warning',
message: warningMessage
});
const recommendationMessage = '💡 RECOMMENDATION: Mount a volume to /data for persistent storage';
this.creationMessages.push({
type: 'info',
message: recommendationMessage
});
} else if (this.isDockerEnvironment() && fallback.persistent) {
const persistentMessage = '✨ Database is in persistent storage - will survive container restarts';
this.creationMessages.push({
type: 'success',
message: persistentMessage
});
}
if (progressCallback) {
progressCallback({
attempt: i + 1,
total: fallbackPaths.length,
location: fallback.description,
path: fallback.path,
messages: [...this.creationMessages],
success: true,
persistent: fallback.persistent,
isDocker: this.isDockerEnvironment()
});
}
return {
success: true,
message: successMessage,
dbPath: this.dbPath,
attemptedLocations: i + 1,
messages: [...this.creationMessages],
persistent: fallback.persistent,
isDocker: this.isDockerEnvironment(),
priority: fallback.priority
};
} catch (error) {
// Failed at this location
const errorMessage = `✗ Failed to create database in ${fallback.description}: ${error.message}`;
this.creationMessages.push({
type: 'error',
message: errorMessage
});
if (progressCallback) {
progressCallback({
attempt: i + 1,
total: fallbackPaths.length,
location: fallback.description,
path: fallback.path,
messages: [...this.creationMessages],
error: error.message
});
}
// If this is the last attempt, throw error
if (i === fallbackPaths.length - 1) {
throw new Error(`Failed to create database in all ${fallbackPaths.length} locations. Last error: ${error.message}`);
}
// Add waiting message before next attempt
const waitMessage = 'Waiting 1.5 seconds before trying next location...';
this.creationMessages.push({
type: 'info',
message: waitMessage
});
if (progressCallback) {
progressCallback({
attempt: i + 1,
total: fallbackPaths.length,
location: 'waiting',
path: 'N/A',
messages: [...this.creationMessages],
waiting: true
});
}
// Wait 1.5 seconds before trying next fallback for better UX
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
}
// Queue management for preventing concurrent access issues
async executeOperation(operation) {
return new Promise((resolve, reject) => {
this.operationQueue.push({ operation, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.isProcessingQueue || this.operationQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const { operation, resolve, reject } = this.operationQueue.shift();
try {
const result = await operation();
resolve(result);
} catch (error) {
// Retry once for SQLITE_BUSY errors
if (error.message.includes('SQLITE_BUSY')) {
try {
// Wait a random amount between 10-100ms to reduce collision
await new Promise(res => setTimeout(res, 10 + Math.random() * 90));
const result = await operation();
resolve(result);
} catch (retryError) {
reject(retryError);
}
} else {
reject(error);
}
}
}
this.isProcessingQueue = false;
}
// Store metrics in database
async storeMetrics(cpuUsage, memoryUsage, memoryRss, eventLoopLag) {
return this.executeOperation(() => {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const roundedCpuUsage = Math.round(cpuUsage * 100) / 100;
const roundedMemoryUsage = Math.round(memoryUsage * 100) / 100;
const roundedEventLoopLag = Math.round(eventLoopLag * 100) / 100;
const stmt = this.db.prepare(`
INSERT INTO performance_metrics (cpu_usage, memory_usage, memory_rss, event_loop_lag)
VALUES (?, ?, ?, ?)
`);
stmt.run(roundedCpuUsage, roundedMemoryUsage, memoryRss, roundedEventLoopLag, function(err) {
stmt.finalize(); // Always finalize, even on error
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
});
}
// Record alert in database
async recordAlert(metricType, threshold, currentValue, durationMinutes) {
return this.executeOperation(() => {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const roundedThreshold = Math.round(threshold * 100) / 100;
const roundedCurrentValue = Math.round(currentValue * 100) / 100;
const roundedDurationMinutes = Math.round(durationMinutes * 100) / 100;
const stmt = this.db.prepare(`
INSERT INTO alert_history (metric_type, threshold_value, current_value, duration_minutes)
VALUES (?, ?, ?, ?)
`);
stmt.run(metricType, roundedThreshold, roundedCurrentValue, roundedDurationMinutes, function(err) {
stmt.finalize(); // Always finalize, even on error
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
});
}
// Calculate averages from database
async getAverages(windowMinutes = 10) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const since = new Date(Date.now() - (windowMinutes * 60 * 1000)).toISOString();
this.db.get(`
SELECT
AVG(cpu_usage) as cpu,
AVG(memory_usage) as memory,
AVG(event_loop_lag) as eventLoop
FROM performance_metrics
WHERE created_at > ?
`, [since], (err, row) => {
if (err) {
reject(err);
} else {
resolve({
cpu: Math.round((row.cpu || 0) * 100) / 100,
memory: Math.round((row.memory || 0) * 100) / 100,
eventLoop: Math.round((row.eventLoop || 0) * 100) / 100
});
}
});
});
}
// Check if metric has been sustained above threshold
async checkSustainedMetric(metricColumn, threshold, durationMs) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const since = new Date(Date.now() - durationMs).toISOString().replace('T', ' ').replace('Z', '');
this.db.get(`
SELECT COUNT(*) as count,
COUNT(CASE WHEN CAST(${metricColumn} AS REAL) > CAST(? AS REAL) THEN 1 END) as above_threshold
FROM performance_metrics
WHERE created_at > ?
`, [threshold, since], (err, row) => {
if (err) {
reject(err);
} else {
// Consider it sustained if more than 80% of readings are above threshold
const sustainedRatio = row.above_threshold / Math.max(row.count, 1);
const isSustained = sustainedRatio > 0.8 && row.count > 5;
resolve({
sustained: isSustained,
ratio: Math.round(sustainedRatio * 100) / 100,
totalReadings: row.count,
exceedingReadings: row.above_threshold
});
}
});
});
}
// Get recent metrics from database
async getRecentMetrics(metricType, count = 10) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const column = metricType === 'cpu' ? 'cpu_usage' :
metricType === 'memory' ? 'memory_usage' :
'event_loop_lag';
this.db.all(`
SELECT created_at, ${column} as value
FROM performance_metrics
ORDER BY created_at DESC
LIMIT ?
`, [count], (err, rows) => {
if (err) {
reject(err);
} else {
const roundedRows = rows.map(row => ({
created_at: row.created_at,
value: Math.round(row.value * 100) / 100
}));
resolve(roundedRows.reverse()); // Return in chronological order
}
});
});
}
// Get alert history from database
async getAlertHistory(limitCount = 50) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
this.db.all(`
SELECT * FROM alert_history
ORDER BY created_at DESC
LIMIT ?
`, [limitCount], (err, rows) => {
if (err) {
reject(err);
} else {
const roundedRows = rows.map(row => ({
...row,
threshold_value: Math.round(row.threshold_value * 100) / 100,
current_value: Math.round(row.current_value * 100) / 100,
duration_minutes: Math.round(row.duration_minutes * 100) / 100
}));
resolve(roundedRows);
}
});
});
}
// Get performance trend from database
async getTrend(metricType, windowMinutes = 60) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const column = metricType === 'cpu' ? 'cpu_usage' :
metricType === 'memory' ? 'memory_usage' :
'event_loop_lag';
const since = new Date(Date.now() - (windowMinutes * 60 * 1000)).toISOString();
const midPoint = new Date(Date.now() - (windowMinutes * 30 * 1000)).toISOString();
this.db.get(`
SELECT
AVG(CASE WHEN created_at < ? THEN ${column} END) as first_half,
AVG(CASE WHEN created_at >= ? THEN ${column} END) as second_half,
COUNT(*) as total_count
FROM performance_metrics
WHERE created_at > ?
`, [midPoint, midPoint, since], (err, row) => {
if (err) {
reject(err);
} else if (row.total_count < 20) {
resolve('insufficient_data');
} else {
const difference = row.second_half - row.first_half;
const threshold = 5; // 5% change threshold
if (difference > threshold) {
resolve('degrading');
} else if (difference < -threshold) {
resolve('improving');
} else {
resolve('stable');
}
}
});
});
}
// Prune old data based on retention policy
async pruneOldData(retentionDays = null) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const days = retentionDays || this.retentionDays;
const cutoffTime = new Date(Date.now() - (days * 24 * 60 * 60 * 1000)).toISOString();
// Use transaction for atomic operation
this.db.serialize(() => {
this.db.run('BEGIN TRANSACTION');
// Delete old performance metrics
this.db.run('DELETE FROM performance_metrics WHERE created_at < ?', [cutoffTime], (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error pruning old metrics: ' + err.message));
return;
}
});
// Delete old alert history
this.db.run('DELETE FROM alert_history WHERE created_at < ?', [cutoffTime], (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error pruning old alerts: ' + err.message));
return;
}
});
// Delete old code quality metrics
this.db.run('DELETE FROM code_quality_metrics WHERE created_at < ?', [cutoffTime], (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error pruning old code quality metrics: ' + err.message));
return;
}
});
// Delete old node quality metrics
this.db.run('DELETE FROM node_quality_metrics WHERE created_at < ?', [cutoffTime], (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error pruning old node quality metrics: ' + err.message));
return;
}
});
// Commit transaction
this.db.run('COMMIT', (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error committing prune operation: ' + err.message));
} else {
resolve({ message: `Pruned data older than ${days} days`, cutoffTime });
}
});
});
});
}
// Get database statistics
async getStats() {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const since24h = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString();
this.db.get(`
SELECT
(SELECT COUNT(*) FROM performance_metrics) as total_metrics,
(SELECT COUNT(*) FROM alert_history) as total_alerts,
(SELECT MIN(created_at) FROM performance_metrics) as oldest_metric,
(SELECT MAX(created_at) FROM performance_metrics) as newest_metric,
(SELECT COUNT(*) FROM performance_metrics WHERE created_at > ?) as recent_metrics
`, [since24h], (err, row) => {
if (err) {
reject(err);
} else {
resolve({
totalMetrics: row.total_metrics,
totalAlerts: row.total_alerts,
oldestMetric: row.oldest_metric,
newestMetric: row.newest_metric,
recentMetrics: row.recent_metrics,
dbPath: this.dbPath
});
}
});
});
}
// Clear all data
async clearAll() {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
this.db.serialize(() => {
this.db.run('BEGIN TRANSACTION');
this.db.run('DELETE FROM performance_metrics', (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error clearing metrics: ' + err.message));
return;
}
});
this.db.run('DELETE FROM alert_history', (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error clearing alerts: ' + err.message));
return;
}
});
this.db.run('COMMIT', (err) => {
if (err) {
this.db.run('ROLLBACK');
reject(new Error('Error committing clear operation: ' + err.message));
} else {
resolve({ message: 'All data cleared successfully' });
}
});
});
});
}
// Store code quality metrics (with UPSERT to prevent duplicates)
async storeCodeQualityMetrics(flowId, flowName, totalIssues, nodesWithIssues, nodesWithCriticalIssues, totalFunctionNodes, issueTypes, qualityScore, complexityScore) {
return this.executeOperation(() => {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const issueTypesJson = JSON.stringify(issueTypes || []);
const roundedQualityScore = Math.round(qualityScore * 100) / 100;
const roundedComplexityScore = Math.round(complexityScore * 100) / 100;
// Insert new data (allowing historical records)
const stmt = this.db.prepare(`
INSERT INTO code_quality_metrics (
flow_id, flow_name, total_issues, nodes_with_issues, nodes_with_critical_issues,
total_function_nodes, issue_types, quality_score, complexity_score
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(flowId, flowName, totalIssues, nodesWithIssues, nodesWithCriticalIssues,
totalFunctionNodes, issueTypesJson, roundedQualityScore, roundedComplexityScore, function(err) {
stmt.finalize();
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
});
}
// Store multiple node quality metrics in a single transaction (BATCH OPERATION)
async storeNodeQualityMetricsBatch(flowId, nodeMetrics) {
return this.executeOperation(() => {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
if (!nodeMetrics || nodeMetrics.length === 0) {
resolve([]);
return;
}
// Use a transaction for batch insert (allowing historical records)
const db = this.db; // Capture database reference to avoid scope issues
db.serialize(() => {
db.run('BEGIN TRANSACTION');
const stmt = db.prepare(`
INSERT INTO node_quality_metrics (
flow_id, node_id, node_name, issues_count, issue_details,
complexity_score, lines_of_code, quality_score
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
let completedInserts = 0;
const totalInserts = nodeMetrics.length;
let hasError = false;
nodeMetrics.forEach(nodeMetric => {
const issueDetailsJson = JSON.stringify(nodeMetric.issueDetails || []);
const roundedComplexityScore = Math.round(nodeMetric.complexityScore * 100) / 100;
const roundedQualityScore = Math.round(nodeMetric.qualityScore * 100) / 100;
stmt.run(flowId, nodeMetric.nodeId, nodeMetric.nodeName, nodeMetric.issuesCount,
issueDetailsJson, roundedComplexityScore, nodeMetric.linesOfCode,
roundedQualityScore, function(err) {
completedInserts++;
if (err && !hasError) {
hasError = true;
stmt.finalize();
db.run('ROLLBACK');
reject(err);
return;
}
if (completedInserts === totalInserts && !hasError) {
stmt.finalize();
db.run('COMMIT', (commitErr) => {
if (commitErr) {
reject(commitErr);
} else {
resolve(nodeMetrics.length);
}
});
}
});
});
});
});
});
}
// Store node-level quality metrics
async storeNodeQualityMetrics(flowId, nodeId, nodeName, issuesCount, issueDetails, complexityScore, linesOfCode, qualityScore) {
return this.executeOperation(() => {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const stmt = this.db.prepare(`
INSERT INTO node_quality_metrics (
flow_id, node_id, node_name, issues_count, issue_details,
complexity_score, lines_of_code, quality_score
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const issueDetailsJson = JSON.stringify(issueDetails || []);
const roundedComplexityScore = Math.round(complexityScore * 100) / 100;
const roundedQualityScore = Math.round(qualityScore * 100) / 100;
stmt.run(flowId, nodeId, nodeName, issuesCount, issueDetailsJson,
roundedComplexityScore, linesOfCode, roundedQualityScore, function(err) {
stmt.finalize();
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
});
}
// Get code quality trends over time (grouped by rounded hours)
async getCodeQualityTrends(hours = 24, limit = 100) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
const since = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString();
this.db.all(`
SELECT
flow_id,
flow_name,
total_issues,
nodes_with_issues,
total_function_nodes,
quality_score,
complexity_score,
created_at,
strftime('%Y-%m-%dT%H:00:00.000Z', datetime(strftime('%Y-%m-%d %H:00:00', created_at), '+1 hour')) as rounded_hour
FROM code_quality_metrics
WHERE created_at > ?
ORDER BY created_at DESC
LIMIT ?
`, [since, limit], (err, rows) => {
if (err) {
reject(err);
} else {
// Group by rounded hour only, averaging across all flows
const hourlyData = {};
rows.forEach(row => {
const hour = row.rounded_hour;
// Skip invalid dates
if (!hour || hour === 'null' || !hour.includes('T')) {
// eslint-disable-next-line no-console
console.warn('Skipping invalid rounded_hour:', hour);
return;
}
if (!hourlyData[hour]) {
hourlyData[hour] = {
flows: [],
created_at: hour
};
}
hourlyData[hour].flows.push(row);
});
// Calculate averages for each hour
const processedRows = Object.keys(hourlyData).map(hour => {
const flows = hourlyData[hour].flows;
const totalFlows = flows.length;
// Validate the hour format before returning
const testDate = new Date(hour);
if (isNaN(testDate.getTime())) {
// eslint-disable-next-line no-console
console.warn('Skipping invalid hour format:', hour);
return null;
}
return {
created_at: hour,
flow_count: totalFlows,
total_issues: Math.round(flows.reduce((sum, f) => sum + f.total_issues, 0) / totalFlows),
nodes_with_issues: Math.round(flows.reduce((sum, f) => sum + f.nodes_with_issues, 0) / totalFlows),
total_function_nodes: Math.round(flows.reduce((sum, f) => sum + f.total_function_nodes, 0) / totalFlows),
quality_score: Math.round((flows.reduce((sum, f) => sum + f.quality_score, 0) / totalFlows) * 100) / 100,
complexity_score: Math.round((flows.reduce((sum, f) => sum + f.complexity_score, 0) / totalFlows) * 100) / 100,
flow_name: `Average across ${totalFlows} flows`
};
}).filter(row => row !== null) // Remove invalid entries
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
resolve(processedRows);
}
});
});
}
// Get current quality summary across all flows
async getQualitySummary() {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
// Get the most recent quality metrics for each flow (with duplicate prevention)
this.db.all(`
SELECT DISTINCT
cqm.flow_id,
cqm.flow_name,
cqm.total_issues,
cqm.nodes_with_issues,
cqm.nodes_with_critical_issues,
cqm.total_function_nodes,
cqm.quality_score,
cqm.complexity_score,
cqm.created_at
FROM code_quality_metrics cqm
INNER JOIN (
SELECT flow_id, MAX(id) as max_id -- Use MAX(id) instead of MAX(created_at) for better uniqueness
FROM code_quality_metrics
GROUP BY flow_id
) latest ON cqm.flow_id = latest.flow_id AND cqm.id = latest.max_id
ORDER BY cqm.quality_score ASC
`, [], (err, rows) => {
if (err) {
reject(err);
} else {
const totalIssues = rows.reduce((sum, row) => sum + row.total_issues, 0);
const totalNodes = rows.reduce((sum, row) => sum + row.total_function_nodes, 0);
const averageQuality = rows.length > 0 ?
rows.reduce((sum, row) => sum + row.quality_score, 0) / rows.length : 100;
const processedRows = rows.map(row => ({
...row,
quality_score: Math.round(row.quality_score * 100) / 100,
complexity_score: Math.round(row.complexity_score * 100) / 100
}));
resolve({
flows: processedRows,
summary: {
totalFlows: rows.length,
totalIssues: totalIssues,
totalNodes: totalNodes,
averageQuality: Math.round(averageQuality * 100) / 100,
worstFlow: rows.length > 0 ? rows[0] : null,
bestFlow: rows.length > 0 ? rows[rows.length - 1] : null
}
});
}
});
});
}
// Get most problematic nodes
async getMostProblematicNodes(limit = 20) {
return new Promise((resolve, reject) => {
if (!this.db || !this.initialized) {
reject(new Error('Database not initialized'));
return;
}
this.db.all(`
SELECT
nqm.flow_id,
nqm.node_id,
nqm.node_name,
nqm.issues_count,
nqm.quality_score,
nqm.complexity_score,
nqm.lines_of_code,
nqm.created_at
FROM node_quality_metrics nqm
INNER JOIN (
SELECT node_id, MAX(created_at) as max_created_at
FROM node_quality_metrics
WHERE issues_count > 0
GROUP BY node_id
) latest ON nqm.node_id = latest.node_id AND nqm.created_at = latest.max_created_at
ORDER BY nqm.issues_count DESC, nqm.quality_score ASC
LIMIT ?
`, [limit], (err, rows) => {
if (err) {
reject(err);
} else {
const processedRows = rows.map(row => ({
...row,
quality_score: Math.round(row.quality_score * 100) / 100,
complexity_score: Math.round(row.complexity_score * 100) / 100
}));
resolve(processedRows);
}
});
});
}
// Set retention policy
setRetentionDays(days) {
this.retentionDays = Math.max(1, Math.min(30, days)); // Between 1 and 30 days
}
// Close database connection
close() {
if (this.db) {
this.db.close((err) => {
if (err) {
// eslint-disable-next-line no-console
console.error('Error closing database:', err);
} else {
// eslint-disable-next-line no-console
console.log('Database connection closed');
}
});
this.db = null;
this.initialized = false;
}
}
}
module.exports = PerformanceDatabase;