fixed the issue with graphs on dashboard not showing information correctly, so now statistics are queued into a new table specifically for this and addd this in automation queue

This commit is contained in:
Muhammad Ibrahim
2025-11-07 10:00:19 +00:00
parent 63831caba3
commit e73ebc383c
6 changed files with 523 additions and 207 deletions

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "system_statistics" (
"id" TEXT NOT NULL,
"unique_packages_count" INTEGER NOT NULL,
"unique_security_count" INTEGER NOT NULL,
"total_packages" INTEGER NOT NULL,
"total_hosts" INTEGER NOT NULL,
"hosts_needing_updates" INTEGER NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_statistics_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "system_statistics_timestamp_idx" ON "system_statistics"("timestamp");

View File

@@ -212,6 +212,18 @@ model update_history {
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade) hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
} }
model system_statistics {
id String @id
unique_packages_count Int
unique_security_count Int
total_packages Int
total_hosts Int
hosts_needing_updates Int
timestamp DateTime @default(now())
@@index([timestamp])
}
model users { model users {
id String @id id String @id
username String @unique username String @unique

View File

@@ -242,6 +242,30 @@ router.post(
}, },
); );
// Trigger manual system statistics collection
router.post(
"/trigger/system-statistics",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerSystemStatistics();
res.json({
success: true,
data: {
jobId: job.id,
message: "System statistics collection triggered successfully",
},
});
} catch (error) {
console.error("Error triggering system statistics collection:", error);
res.status(500).json({
success: false,
error: "Failed to trigger system statistics collection",
});
}
},
);
// Get queue health status // Get queue health status
router.get("/health", authenticateToken, async (_req, res) => { router.get("/health", authenticateToken, async (_req, res) => {
try { try {
@@ -300,6 +324,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1), queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1),
queueManager.getRecentJobs(QUEUE_NAMES.SYSTEM_STATISTICS, 1),
]); ]);
// Calculate overview metrics // Calculate overview metrics
@@ -309,21 +334,24 @@ router.get("/overview", authenticateToken, async (_req, res) => {
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed + stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed, stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed +
stats[QUEUE_NAMES.SYSTEM_STATISTICS].delayed,
runningTasks: runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active + stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active + stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active, stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active +
stats[QUEUE_NAMES.SYSTEM_STATISTICS].active,
failedTasks: failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed + stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed + stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed, stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed +
stats[QUEUE_NAMES.SYSTEM_STATISTICS].failed,
totalAutomations: Object.values(stats).reduce((sum, queueStats) => { totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
return ( return (
@@ -435,6 +463,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
: "Never run", : "Never run",
stats: stats[QUEUE_NAMES.AGENT_COMMANDS], stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
}, },
{
name: "System Statistics Collection",
queue: QUEUE_NAMES.SYSTEM_STATISTICS,
description: "Collects aggregated system-wide package statistics",
schedule: "Every 30 minutes",
lastRun: recentJobs[6][0]?.finishedOn
? new Date(recentJobs[6][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[6][0]?.finishedOn || 0,
status: recentJobs[6][0]?.failedReason
? "Failed"
: recentJobs[6][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.SYSTEM_STATISTICS],
},
].sort((a, b) => { ].sort((a, b) => {
// Sort by last run timestamp (most recent first) // Sort by last run timestamp (most recent first)
// If both have never run (timestamp 0), maintain original order // If both have never run (timestamp 0), maintain original order

View File

@@ -564,22 +564,43 @@ router.get(
const startDate = new Date(); const startDate = new Date();
startDate.setDate(endDate.getDate() - daysInt); startDate.setDate(endDate.getDate() - daysInt);
// Build where clause // Determine if we need aggregation based on host filter
const whereClause = { const needsAggregation =
!hostId || hostId === "all" || hostId === "undefined";
let trendsData;
if (needsAggregation) {
// For "All Hosts" mode, use system_statistics table
trendsData = await prisma.system_statistics.findMany({
where: {
timestamp: { timestamp: {
gte: startDate, gte: startDate,
lte: endDate, lte: endDate,
}, },
}; },
select: {
// Add host filter if specified timestamp: true,
if (hostId && hostId !== "all" && hostId !== "undefined") { unique_packages_count: true,
whereClause.host_id = hostId; unique_security_count: true,
} total_packages: true,
total_hosts: true,
// Get all update history records in the date range hosts_needing_updates: true,
const trendsData = await prisma.update_history.findMany({ },
where: whereClause, orderBy: {
timestamp: "asc",
},
});
} else {
// For individual host, use update_history table
trendsData = await prisma.update_history.findMany({
where: {
host_id: hostId,
timestamp: {
gte: startDate,
lte: endDate,
},
},
select: { select: {
timestamp: true, timestamp: true,
packages_count: true, packages_count: true,
@@ -592,9 +613,74 @@ router.get(
timestamp: "asc", timestamp: "asc",
}, },
}); });
}
// Enhanced data validation and processing // Process data based on source
const processedData = trendsData let processedData;
let aggregatedArray;
if (needsAggregation) {
// For "All Hosts" mode, data comes from system_statistics table
// Already aggregated, just need to format it
processedData = trendsData
.filter((record) => {
// Enhanced validation
return (
record.total_packages !== null &&
record.total_packages >= 0 &&
record.unique_packages_count >= 0 &&
record.unique_security_count >= 0 &&
record.unique_security_count <= record.unique_packages_count
);
})
.map((record) => {
const date = new Date(record.timestamp);
let timeKey;
if (daysInt <= 1) {
// For "Last 24 hours", use full timestamp for each data point
// This allows plotting all individual data points
timeKey = date.toISOString(); // Full ISO timestamp
} else {
// For daily view, group by day
timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
}
return {
timeKey,
total_packages: record.total_packages,
packages_count: record.unique_packages_count,
security_count: record.unique_security_count,
timestamp: record.timestamp,
};
});
if (daysInt <= 1) {
// For "Last 24 hours", use all individual data points without grouping
// Sort by timestamp
aggregatedArray = processedData.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
} else {
// For longer periods, group by timeKey and take the latest value for each period
const aggregatedData = processedData.reduce((acc, item) => {
if (
!acc[item.timeKey] ||
item.timestamp > acc[item.timeKey].timestamp
) {
acc[item.timeKey] = item;
}
return acc;
}, {});
// Convert to array and sort
aggregatedArray = Object.values(aggregatedData).sort((a, b) =>
a.timeKey.localeCompare(b.timeKey),
);
}
} else {
// For individual host, data comes from update_history table
processedData = trendsData
.filter((record) => { .filter((record) => {
// Enhanced validation // Enhanced validation
return ( return (
@@ -602,17 +688,18 @@ router.get(
record.total_packages >= 0 && record.total_packages >= 0 &&
record.packages_count >= 0 && record.packages_count >= 0 &&
record.security_count >= 0 && record.security_count >= 0 &&
record.security_count <= record.packages_count && // Security can't exceed outdated record.security_count <= record.packages_count &&
record.status === "success" record.status === "success"
); // Only include successful reports );
}) })
.map((record) => { .map((record) => {
const date = new Date(record.timestamp); const date = new Date(record.timestamp);
let timeKey; let timeKey;
if (daysInt <= 1) { if (daysInt <= 1) {
// For hourly view, group by hour only (not minutes) // For "Last 24 hours", use full timestamp for each data point
timeKey = date.toISOString().substring(0, 13); // YYYY-MM-DDTHH // This allows plotting all individual data points
timeKey = date.toISOString(); // Full ISO timestamp
} else { } else {
// For daily view, group by day // For daily view, group by day
timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
@@ -628,70 +715,14 @@ router.get(
}; };
}); });
// Determine if we need aggregation based on host filter if (daysInt <= 1) {
const needsAggregation = // For "Last 24 hours", use all individual data points without grouping
!hostId || hostId === "all" || hostId === "undefined"; // Sort by timestamp
aggregatedArray = processedData.sort(
let aggregatedArray; (a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
if (needsAggregation) {
// For "All Hosts" mode, we need to calculate the actual total packages differently
// Instead of aggregating historical data (which is per-host), we'll use the current total
// and show that as a flat line, since total packages don't change much over time
// Get the current total packages count (unique packages across all hosts)
const currentTotalPackages = await prisma.packages.count({
where: {
host_packages: {
some: {}, // At least one host has this package
},
},
});
// Aggregate data by timeKey when looking at "All Hosts" or no specific host
const aggregatedData = processedData.reduce((acc, item) => {
if (!acc[item.timeKey]) {
acc[item.timeKey] = {
timeKey: item.timeKey,
total_packages: currentTotalPackages, // Use current total packages
packages_count: 0,
security_count: 0,
record_count: 0,
host_ids: new Set(),
min_timestamp: item.timestamp,
max_timestamp: item.timestamp,
};
}
// For outdated and security packages: SUM (these represent counts across hosts)
acc[item.timeKey].packages_count += item.packages_count;
acc[item.timeKey].security_count += item.security_count;
acc[item.timeKey].record_count += 1;
acc[item.timeKey].host_ids.add(item.host_id);
// Track timestamp range
if (item.timestamp < acc[item.timeKey].min_timestamp) {
acc[item.timeKey].min_timestamp = item.timestamp;
}
if (item.timestamp > acc[item.timeKey].max_timestamp) {
acc[item.timeKey].max_timestamp = item.timestamp;
}
return acc;
}, {});
// Convert to array and add metadata
aggregatedArray = Object.values(aggregatedData)
.map((item) => ({
...item,
host_count: item.host_ids.size,
host_ids: Array.from(item.host_ids),
}))
.sort((a, b) => a.timeKey.localeCompare(b.timeKey));
} else { } else {
// For specific host, show individual data points without aggregation // For longer periods, group by timeKey to handle multiple reports from same host in same time period
// But still group by timeKey to handle multiple reports from same host in same time period
const hostAggregatedData = processedData.reduce((acc, item) => { const hostAggregatedData = processedData.reduce((acc, item) => {
if (!acc[item.timeKey]) { if (!acc[item.timeKey]) {
acc[item.timeKey] = { acc[item.timeKey] = {
@@ -729,9 +760,20 @@ router.get(
})) }))
.sort((a, b) => a.timeKey.localeCompare(b.timeKey)); .sort((a, b) => a.timeKey.localeCompare(b.timeKey));
} }
}
// Handle sparse data by filling missing time periods // Handle sparse data by filling missing time periods
const fillMissingPeriods = (data, daysInt) => { const fillMissingPeriods = (data, daysInt) => {
if (data.length === 0) {
return [];
}
// For "Last 24 hours", return data as-is without filling gaps
// This allows plotting all individual data points
if (daysInt <= 1) {
return data;
}
const filledData = []; const filledData = [];
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - daysInt); startDate.setDate(startDate.getDate() - daysInt);
@@ -741,43 +783,49 @@ router.get(
const endDate = new Date(); const endDate = new Date();
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
// Find the last known values for interpolation // Sort data by timeKey to get chronological order
const sortedData = [...data].sort((a, b) =>
a.timeKey.localeCompare(b.timeKey),
);
// Find the first actual data point (don't fill before this)
const firstDataPoint = sortedData[0];
const firstDataTimeKey = firstDataPoint?.timeKey;
// Track last known values as we iterate forward
let lastKnownValues = null; let lastKnownValues = null;
if (data.length > 0) { let hasSeenFirstDataPoint = false;
lastKnownValues = {
total_packages: data[0].total_packages,
packages_count: data[0].packages_count,
security_count: data[0].security_count,
};
}
while (currentDate <= endDate) { while (currentDate <= endDate) {
let timeKey; let timeKey;
if (daysInt <= 1) { // For daily view, group by day
timeKey = currentDate.toISOString().substring(0, 13); // Hourly timeKey = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
currentDate.setHours(currentDate.getHours() + 1);
} else {
timeKey = currentDate.toISOString().split("T")[0]; // Daily
currentDate.setDate(currentDate.getDate() + 1); currentDate.setDate(currentDate.getDate() + 1);
// Skip periods before the first actual data point
if (firstDataTimeKey && timeKey < firstDataTimeKey) {
continue;
} }
if (dataMap.has(timeKey)) { if (dataMap.has(timeKey)) {
const item = dataMap.get(timeKey); const item = dataMap.get(timeKey);
filledData.push(item); filledData.push(item);
// Update last known values // Update last known values with actual data
lastKnownValues = { lastKnownValues = {
total_packages: item.total_packages, total_packages: item.total_packages || 0,
packages_count: item.packages_count, packages_count: item.packages_count || 0,
security_count: item.security_count, security_count: item.security_count || 0,
}; };
hasSeenFirstDataPoint = true;
} else { } else {
// For missing periods, use the last known values (interpolation) // For missing periods AFTER the first data point, use forward-fill
// This creates a continuous line instead of gaps // Only fill if we have a last known value and we've seen the first data point
if (lastKnownValues !== null && hasSeenFirstDataPoint) {
filledData.push({ filledData.push({
timeKey, timeKey,
total_packages: lastKnownValues?.total_packages || 0, total_packages: lastKnownValues.total_packages,
packages_count: lastKnownValues?.packages_count || 0, packages_count: lastKnownValues.packages_count,
security_count: lastKnownValues?.security_count || 0, security_count: lastKnownValues.security_count,
record_count: 0, record_count: 0,
host_count: 0, host_count: 0,
host_ids: [], host_ids: [],
@@ -786,6 +834,8 @@ router.get(
isInterpolated: true, // Mark as interpolated for debugging isInterpolated: true, // Mark as interpolated for debugging
}); });
} }
// If we haven't seen the first data point yet, skip this period
}
} }
return filledData; return filledData;
@@ -810,7 +860,7 @@ router.get(
// Get current package state for offline fallback // Get current package state for offline fallback
let currentPackageState = null; let currentPackageState = null;
if (hostId && hostId !== "all" && hostId !== "undefined") { if (hostId && hostId !== "all" && hostId !== "undefined") {
// Get current package counts for specific host // For individual host, get current package counts from host_packages
const currentState = await prisma.host_packages.aggregate({ const currentState = await prisma.host_packages.aggregate({
where: { where: {
host_id: hostId, host_id: hostId,
@@ -841,8 +891,29 @@ router.get(
security_count: securityCount, security_count: securityCount,
}; };
} else { } else {
// Get current package counts for all hosts // For "All Hosts" mode, use the latest system_statistics record if available
// Total packages = count of unique packages installed on at least one host // Otherwise calculate from database
const latestStats = await prisma.system_statistics.findFirst({
orderBy: {
timestamp: "desc",
},
select: {
total_packages: true,
unique_packages_count: true,
unique_security_count: true,
timestamp: true,
},
});
if (latestStats) {
// Use latest system statistics (collected by scheduled job)
currentPackageState = {
total_packages: latestStats.total_packages,
packages_count: latestStats.unique_packages_count,
security_count: latestStats.unique_security_count,
};
} else {
// Fallback: calculate from database if no statistics collected yet
const totalPackagesCount = await prisma.packages.count({ const totalPackagesCount = await prisma.packages.count({
where: { where: {
host_packages: { host_packages: {
@@ -851,25 +922,34 @@ router.get(
}, },
}); });
// Get counts for boolean fields separately const uniqueOutdatedCount = await prisma.packages.count({
const outdatedCount = await prisma.host_packages.count({
where: { where: {
host_packages: {
some: {
needs_update: true, needs_update: true,
}, },
},
},
}); });
const securityCount = await prisma.host_packages.count({ const uniqueSecurityCount = await prisma.packages.count({
where: { where: {
host_packages: {
some: {
needs_update: true,
is_security_update: true, is_security_update: true,
}, },
},
},
}); });
currentPackageState = { currentPackageState = {
total_packages: totalPackagesCount, total_packages: totalPackagesCount,
packages_count: outdatedCount, packages_count: uniqueOutdatedCount,
security_count: securityCount, security_count: uniqueSecurityCount,
}; };
} }
}
// Format data for chart // Format data for chart
const chartData = { const chartData = {
@@ -923,6 +1003,11 @@ router.get(
chartData.datasets[2].data.push(item.security_count); chartData.datasets[2].data.push(item.security_count);
}); });
// Replace the last label with "Now" to indicate current state
if (chartData.labels.length > 0) {
chartData.labels[chartData.labels.length - 1] = "Now";
}
// Calculate data quality metrics // Calculate data quality metrics
const dataQuality = { const dataQuality = {
totalRecords: trendsData.length, totalRecords: trendsData.length,

View File

@@ -13,6 +13,7 @@ const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup"); const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck"); const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
const MetricsReporting = require("./metricsReporting"); const MetricsReporting = require("./metricsReporting");
const SystemStatistics = require("./systemStatistics");
// Queue names // Queue names
const QUEUE_NAMES = { const QUEUE_NAMES = {
@@ -23,6 +24,7 @@ const QUEUE_NAMES = {
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup", DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check", DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
METRICS_REPORTING: "metrics-reporting", METRICS_REPORTING: "metrics-reporting",
SYSTEM_STATISTICS: "system-statistics",
AGENT_COMMANDS: "agent-commands", AGENT_COMMANDS: "agent-commands",
}; };
@@ -106,6 +108,9 @@ class QueueManager {
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting( this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
this, this,
); );
this.automations[QUEUE_NAMES.SYSTEM_STATISTICS] = new SystemStatistics(
this,
);
console.log("✅ All automation classes initialized"); console.log("✅ All automation classes initialized");
} }
@@ -191,6 +196,15 @@ class QueueManager {
workerOptions, workerOptions,
); );
// System Statistics Worker
this.workers[QUEUE_NAMES.SYSTEM_STATISTICS] = new Worker(
QUEUE_NAMES.SYSTEM_STATISTICS,
this.automations[QUEUE_NAMES.SYSTEM_STATISTICS].process.bind(
this.automations[QUEUE_NAMES.SYSTEM_STATISTICS],
),
workerOptions,
);
// Agent Commands Worker // Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker( this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS, QUEUE_NAMES.AGENT_COMMANDS,
@@ -323,6 +337,7 @@ class QueueManager {
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule(); await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule(); await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule(); await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
await this.automations[QUEUE_NAMES.SYSTEM_STATISTICS].schedule();
} }
/** /**
@@ -358,6 +373,10 @@ class QueueManager {
].triggerManual(); ].triggerManual();
} }
async triggerSystemStatistics() {
return this.automations[QUEUE_NAMES.SYSTEM_STATISTICS].triggerManual();
}
async triggerMetricsReporting() { async triggerMetricsReporting() {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual(); return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
} }

View File

@@ -0,0 +1,140 @@
const { prisma } = require("./shared/prisma");
const { v4: uuidv4 } = require("uuid");
/**
* System Statistics Collection Automation
* Collects aggregated system-wide statistics every 30 minutes
* for use in package trends charts
*/
class SystemStatistics {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "system-statistics";
}
/**
* Process system statistics collection job
*/
async process(_job) {
const startTime = Date.now();
console.log("📊 Starting system statistics collection...");
try {
// Calculate unique package counts across all hosts
const uniquePackagesCount = await prisma.packages.count({
where: {
host_packages: {
some: {
needs_update: true,
},
},
},
});
const uniqueSecurityCount = await prisma.packages.count({
where: {
host_packages: {
some: {
needs_update: true,
is_security_update: true,
},
},
},
});
// Calculate total unique packages installed on at least one host
const totalPackages = await prisma.packages.count({
where: {
host_packages: {
some: {}, // At least one host has this package
},
},
});
// Calculate total hosts
const totalHosts = await prisma.hosts.count({
where: {
status: "active",
},
});
// Calculate hosts needing updates (distinct hosts with packages needing updates)
const hostsNeedingUpdates = await prisma.hosts.count({
where: {
status: "active",
host_packages: {
some: {
needs_update: true,
},
},
},
});
// Store statistics in database
await prisma.system_statistics.create({
data: {
id: uuidv4(),
unique_packages_count: uniquePackagesCount,
unique_security_count: uniqueSecurityCount,
total_packages: totalPackages,
total_hosts: totalHosts,
hosts_needing_updates: hostsNeedingUpdates,
timestamp: new Date(),
},
});
const executionTime = Date.now() - startTime;
console.log(
`✅ System statistics collection completed in ${executionTime}ms - Unique packages: ${uniquePackagesCount}, Security: ${uniqueSecurityCount}, Total hosts: ${totalHosts}`,
);
return {
success: true,
uniquePackagesCount,
uniqueSecurityCount,
totalPackages,
totalHosts,
hostsNeedingUpdates,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ System statistics collection failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring system statistics collection (every 30 minutes)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"system-statistics",
{},
{
repeat: { pattern: "*/30 * * * *" }, // Every 30 minutes
jobId: "system-statistics-recurring",
},
);
console.log("✅ System statistics collection scheduled (every 30 minutes)");
return job;
}
/**
* Trigger manual system statistics collection
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"system-statistics-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual system statistics collection triggered");
return job;
}
}
module.exports = SystemStatistics;