mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-25 00:53:48 +00:00
Refactored code to remove duplicate backend api endpoints for counting Improved connection persistence issues Improved database connection pooling issues Fixed redis connection efficiency Changed version to 1.3.0 Fixed GO binary detection based on package manager rather than OS
1306 lines
35 KiB
JavaScript
1306 lines
35 KiB
JavaScript
const express = require("express");
|
|
const { getPrismaClient } = require("../config/prisma");
|
|
const moment = require("moment");
|
|
const { authenticateToken } = require("../middleware/auth");
|
|
const {
|
|
requireViewDashboard,
|
|
requireViewHosts,
|
|
requireViewPackages,
|
|
requireViewUsers,
|
|
} = require("../middleware/permissions");
|
|
const { queueManager } = require("../services/automation");
|
|
|
|
const router = express.Router();
|
|
const prisma = getPrismaClient();
|
|
|
|
// Get dashboard statistics
|
|
router.get(
|
|
"/stats",
|
|
authenticateToken,
|
|
requireViewDashboard,
|
|
async (_req, res) => {
|
|
try {
|
|
const now = new Date();
|
|
|
|
// Get the agent update interval setting
|
|
const settings = await prisma.settings.findFirst();
|
|
const updateIntervalMinutes = settings?.update_interval || 60; // Default to 60 minutes if no setting
|
|
|
|
// Calculate the threshold based on the actual update interval
|
|
// Use 2x the update interval as the threshold for "errored" hosts
|
|
const thresholdMinutes = updateIntervalMinutes * 2;
|
|
const thresholdTime = moment(now)
|
|
.subtract(thresholdMinutes, "minutes")
|
|
.toDate();
|
|
|
|
// Get all statistics in parallel for better performance
|
|
const [
|
|
totalHosts,
|
|
hostsNeedingUpdates,
|
|
totalOutdatedPackages,
|
|
erroredHosts,
|
|
securityUpdates,
|
|
offlineHosts,
|
|
totalHostGroups,
|
|
totalUsers,
|
|
totalRepos,
|
|
osDistribution,
|
|
updateTrends,
|
|
] = await Promise.all([
|
|
// Total hosts count (all hosts regardless of status)
|
|
prisma.hosts.count(),
|
|
|
|
// Hosts needing updates (distinct hosts with packages needing updates)
|
|
prisma.hosts.count({
|
|
where: {
|
|
host_packages: {
|
|
some: {
|
|
needs_update: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
|
|
// Total unique packages that need updates
|
|
prisma.packages.count({
|
|
where: {
|
|
host_packages: {
|
|
some: {
|
|
needs_update: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
|
|
// Errored hosts (not updated within threshold based on update interval)
|
|
prisma.hosts.count({
|
|
where: {
|
|
status: "active",
|
|
last_update: {
|
|
lt: thresholdTime,
|
|
},
|
|
},
|
|
}),
|
|
|
|
// Security updates count (unique packages)
|
|
prisma.packages.count({
|
|
where: {
|
|
host_packages: {
|
|
some: {
|
|
needs_update: true,
|
|
is_security_update: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
|
|
// Offline/Stale hosts (not updated within 3x the update interval)
|
|
prisma.hosts.count({
|
|
where: {
|
|
status: "active",
|
|
last_update: {
|
|
lt: moment(now)
|
|
.subtract(updateIntervalMinutes * 3, "minutes")
|
|
.toDate(),
|
|
},
|
|
},
|
|
}),
|
|
|
|
// Total host groups count
|
|
prisma.host_groups.count(),
|
|
|
|
// Total users count
|
|
prisma.users.count(),
|
|
|
|
// Total repositories count
|
|
prisma.repositories.count(),
|
|
|
|
// OS distribution for pie chart
|
|
prisma.hosts.groupBy({
|
|
by: ["os_type"],
|
|
where: { status: "active" },
|
|
_count: {
|
|
os_type: true,
|
|
},
|
|
}),
|
|
|
|
// Update trends for the last 7 days
|
|
prisma.update_history.groupBy({
|
|
by: ["timestamp"],
|
|
where: {
|
|
timestamp: {
|
|
gte: moment(now).subtract(7, "days").toDate(),
|
|
},
|
|
},
|
|
_count: {
|
|
id: true,
|
|
},
|
|
_sum: {
|
|
packages_count: true,
|
|
security_count: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// Format OS distribution for pie chart
|
|
const osDistributionFormatted = osDistribution.map((item) => ({
|
|
name: item.os_type,
|
|
count: item._count.os_type,
|
|
}));
|
|
|
|
// Calculate update status distribution
|
|
const updateStatusDistribution = [
|
|
{ name: "Up to date", count: totalHosts - hostsNeedingUpdates },
|
|
{ name: "Needs updates", count: hostsNeedingUpdates },
|
|
{ name: "Errored", count: erroredHosts },
|
|
];
|
|
|
|
// Package update priority distribution
|
|
const regularUpdates = Math.max(
|
|
0,
|
|
totalOutdatedPackages - securityUpdates,
|
|
);
|
|
const packageUpdateDistribution = [
|
|
{ name: "Security", count: securityUpdates },
|
|
{ name: "Regular", count: regularUpdates },
|
|
];
|
|
|
|
res.json({
|
|
cards: {
|
|
totalHosts,
|
|
hostsNeedingUpdates,
|
|
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
|
|
totalOutdatedPackages,
|
|
erroredHosts,
|
|
securityUpdates,
|
|
offlineHosts,
|
|
totalHostGroups,
|
|
totalUsers,
|
|
totalRepos,
|
|
},
|
|
charts: {
|
|
osDistribution: osDistributionFormatted,
|
|
updateStatusDistribution,
|
|
packageUpdateDistribution,
|
|
},
|
|
trends: updateTrends,
|
|
lastUpdated: now.toISOString(),
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching dashboard stats:", error);
|
|
res.status(500).json({ error: "Failed to fetch dashboard statistics" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get hosts with their update status
|
|
router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
|
try {
|
|
const hosts = await prisma.hosts.findMany({
|
|
// Show all hosts regardless of status
|
|
select: {
|
|
id: true,
|
|
machine_id: true,
|
|
friendly_name: true,
|
|
hostname: true,
|
|
ip: true,
|
|
os_type: true,
|
|
os_version: true,
|
|
last_update: true,
|
|
status: true,
|
|
agent_version: true,
|
|
auto_update: true,
|
|
notes: true,
|
|
api_id: true,
|
|
host_group_memberships: {
|
|
include: {
|
|
host_groups: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
_count: {
|
|
select: {
|
|
host_packages: {
|
|
where: {
|
|
needs_update: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { last_update: "desc" },
|
|
});
|
|
|
|
// Get update counts for each host separately
|
|
const hostsWithUpdateInfo = await Promise.all(
|
|
hosts.map(async (host) => {
|
|
const updatesCount = await prisma.host_packages.count({
|
|
where: {
|
|
host_id: host.id,
|
|
needs_update: true,
|
|
},
|
|
});
|
|
|
|
// Get total packages count for this host
|
|
const totalPackagesCount = await prisma.host_packages.count({
|
|
where: {
|
|
host_id: host.id,
|
|
},
|
|
});
|
|
|
|
// Get the agent update interval setting for stale calculation
|
|
const settings = await prisma.settings.findFirst();
|
|
const updateIntervalMinutes = settings?.update_interval || 60;
|
|
const thresholdMinutes = updateIntervalMinutes * 2;
|
|
|
|
// Calculate effective status based on reporting interval
|
|
const isStale = moment(host.last_update).isBefore(
|
|
moment().subtract(thresholdMinutes, "minutes"),
|
|
);
|
|
let effectiveStatus = host.status;
|
|
|
|
// Override status if host hasn't reported within threshold
|
|
if (isStale && host.status === "active") {
|
|
effectiveStatus = "inactive";
|
|
}
|
|
|
|
return {
|
|
...host,
|
|
updatesCount,
|
|
totalPackagesCount,
|
|
isStale,
|
|
effectiveStatus,
|
|
};
|
|
}),
|
|
);
|
|
|
|
res.json(hostsWithUpdateInfo);
|
|
} catch (error) {
|
|
console.error("Error fetching hosts:", error);
|
|
res.status(500).json({ error: "Failed to fetch hosts" });
|
|
}
|
|
});
|
|
|
|
// Get packages that need updates across all hosts
|
|
router.get(
|
|
"/packages",
|
|
authenticateToken,
|
|
requireViewPackages,
|
|
async (_req, res) => {
|
|
try {
|
|
const packages = await prisma.packages.findMany({
|
|
where: {
|
|
host_packages: {
|
|
some: {
|
|
needs_update: true,
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
description: true,
|
|
category: true,
|
|
latest_version: true,
|
|
host_packages: {
|
|
where: { needs_update: true },
|
|
select: {
|
|
current_version: true,
|
|
available_version: true,
|
|
is_security_update: true,
|
|
hosts: {
|
|
select: {
|
|
id: true,
|
|
friendly_name: true,
|
|
os_type: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
name: "asc",
|
|
},
|
|
});
|
|
|
|
const packagesWithHostInfo = packages.map((pkg) => ({
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
description: pkg.description,
|
|
category: pkg.category,
|
|
latestVersion: pkg.latest_version,
|
|
affectedHostsCount: pkg.host_packages.length,
|
|
isSecurityUpdate: pkg.host_packages.some((hp) => hp.is_security_update),
|
|
affectedHosts: pkg.host_packages.map((hp) => ({
|
|
hostId: hp.hosts.id,
|
|
friendlyName: hp.hosts.friendly_name,
|
|
osType: hp.hosts.os_type,
|
|
currentVersion: hp.current_version,
|
|
availableVersion: hp.available_version,
|
|
isSecurityUpdate: hp.is_security_update,
|
|
})),
|
|
}));
|
|
|
|
res.json(packagesWithHostInfo);
|
|
} catch (error) {
|
|
console.error("Error fetching packages:", error);
|
|
res.status(500).json({ error: "Failed to fetch packages" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get detailed host information
|
|
router.get(
|
|
"/hosts/:hostId",
|
|
authenticateToken,
|
|
requireViewHosts,
|
|
async (req, res) => {
|
|
try {
|
|
const { hostId } = req.params;
|
|
|
|
const limit = parseInt(req.query.limit, 10) || 10;
|
|
const offset = parseInt(req.query.offset, 10) || 0;
|
|
|
|
const [host, totalHistoryCount] = await Promise.all([
|
|
prisma.hosts.findUnique({
|
|
where: { id: hostId },
|
|
include: {
|
|
host_group_memberships: {
|
|
include: {
|
|
host_groups: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
host_packages: {
|
|
include: {
|
|
packages: true,
|
|
},
|
|
orderBy: {
|
|
needs_update: "desc",
|
|
},
|
|
},
|
|
update_history: {
|
|
orderBy: {
|
|
timestamp: "desc",
|
|
},
|
|
take: limit,
|
|
skip: offset,
|
|
},
|
|
},
|
|
}),
|
|
prisma.update_history.count({
|
|
where: { host_id: hostId },
|
|
}),
|
|
]);
|
|
|
|
if (!host) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
const hostWithStats = {
|
|
...host,
|
|
stats: {
|
|
total_packages: host.host_packages.length,
|
|
outdated_packages: host.host_packages.filter((hp) => hp.needs_update)
|
|
.length,
|
|
security_updates: host.host_packages.filter(
|
|
(hp) => hp.needs_update && hp.is_security_update,
|
|
).length,
|
|
},
|
|
pagination: {
|
|
total: totalHistoryCount,
|
|
limit,
|
|
offset,
|
|
hasMore: offset + limit < totalHistoryCount,
|
|
},
|
|
};
|
|
|
|
res.json(hostWithStats);
|
|
} catch (error) {
|
|
console.error("Error fetching host details:", error);
|
|
res.status(500).json({ error: "Failed to fetch host details" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get agent queue status for a specific host
|
|
router.get(
|
|
"/hosts/:hostId/queue",
|
|
authenticateToken,
|
|
requireViewHosts,
|
|
async (req, res) => {
|
|
try {
|
|
const { hostId } = req.params;
|
|
const { limit = 20 } = req.query;
|
|
|
|
// Get the host to find its API ID
|
|
const host = await prisma.hosts.findUnique({
|
|
where: { id: hostId },
|
|
select: { api_id: true, friendly_name: true },
|
|
});
|
|
|
|
if (!host) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
// Get queue jobs for this host
|
|
const queueData = await queueManager.getHostJobs(
|
|
host.api_id,
|
|
parseInt(limit, 10),
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
hostId,
|
|
apiId: host.api_id,
|
|
friendlyName: host.friendly_name,
|
|
...queueData,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching host queue status:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: "Failed to fetch host queue status",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get recent users ordered by last_login desc
|
|
router.get(
|
|
"/recent-users",
|
|
authenticateToken,
|
|
requireViewUsers,
|
|
async (_req, res) => {
|
|
try {
|
|
const users = await prisma.users.findMany({
|
|
where: {
|
|
last_login: {
|
|
not: null,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
role: true,
|
|
last_login: true,
|
|
created_at: true,
|
|
},
|
|
orderBy: [{ last_login: "desc" }, { created_at: "desc" }],
|
|
take: 5,
|
|
});
|
|
|
|
res.json(users);
|
|
} catch (error) {
|
|
console.error("Error fetching recent users:", error);
|
|
res.status(500).json({ error: "Failed to fetch recent users" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get recent hosts that have sent data (ordered by last_update desc)
|
|
router.get(
|
|
"/recent-collection",
|
|
authenticateToken,
|
|
requireViewHosts,
|
|
async (_req, res) => {
|
|
try {
|
|
const hosts = await prisma.hosts.findMany({
|
|
select: {
|
|
id: true,
|
|
friendly_name: true,
|
|
hostname: true,
|
|
last_update: true,
|
|
status: true,
|
|
},
|
|
orderBy: {
|
|
last_update: "desc",
|
|
},
|
|
take: 5,
|
|
});
|
|
|
|
res.json(hosts);
|
|
} catch (error) {
|
|
console.error("Error fetching recent collection:", error);
|
|
res.status(500).json({ error: "Failed to fetch recent collection" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get package trends over time
|
|
router.get(
|
|
"/package-trends",
|
|
authenticateToken,
|
|
requireViewHosts,
|
|
async (req, res) => {
|
|
try {
|
|
const { days = 30, hostId } = req.query;
|
|
const daysInt = parseInt(days, 10);
|
|
|
|
// Calculate date range
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(endDate.getDate() - daysInt);
|
|
|
|
// Build where clause
|
|
const whereClause = {
|
|
timestamp: {
|
|
gte: startDate,
|
|
lte: endDate,
|
|
},
|
|
};
|
|
|
|
// Add host filter if specified
|
|
if (hostId && hostId !== "all" && hostId !== "undefined") {
|
|
whereClause.host_id = hostId;
|
|
}
|
|
|
|
// Get all update history records in the date range
|
|
const trendsData = await prisma.update_history.findMany({
|
|
where: whereClause,
|
|
select: {
|
|
timestamp: true,
|
|
packages_count: true,
|
|
security_count: true,
|
|
total_packages: true,
|
|
host_id: true,
|
|
status: true,
|
|
},
|
|
orderBy: {
|
|
timestamp: "asc",
|
|
},
|
|
});
|
|
|
|
// Enhanced data validation and processing
|
|
const processedData = trendsData
|
|
.filter((record) => {
|
|
// Enhanced validation
|
|
return (
|
|
record.total_packages !== null &&
|
|
record.total_packages >= 0 &&
|
|
record.packages_count >= 0 &&
|
|
record.security_count >= 0 &&
|
|
record.security_count <= record.packages_count && // Security can't exceed outdated
|
|
record.status === "success"
|
|
); // Only include successful reports
|
|
})
|
|
.map((record) => {
|
|
const date = new Date(record.timestamp);
|
|
let timeKey;
|
|
|
|
if (daysInt <= 1) {
|
|
// For hourly view, group by hour only (not minutes)
|
|
timeKey = date.toISOString().substring(0, 13); // YYYY-MM-DDTHH
|
|
} 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.packages_count || 0,
|
|
security_count: record.security_count || 0,
|
|
host_id: record.host_id,
|
|
timestamp: record.timestamp,
|
|
};
|
|
});
|
|
|
|
// Determine if we need aggregation based on host filter
|
|
const needsAggregation =
|
|
!hostId || hostId === "all" || hostId === "undefined";
|
|
|
|
let aggregatedArray;
|
|
|
|
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 {
|
|
// For specific host, show individual data points without aggregation
|
|
// But still group by timeKey to handle multiple reports from same host in same time period
|
|
const hostAggregatedData = processedData.reduce((acc, item) => {
|
|
if (!acc[item.timeKey]) {
|
|
acc[item.timeKey] = {
|
|
timeKey: item.timeKey,
|
|
total_packages: 0,
|
|
packages_count: 0,
|
|
security_count: 0,
|
|
record_count: 0,
|
|
host_ids: new Set([item.host_id]),
|
|
min_timestamp: item.timestamp,
|
|
max_timestamp: item.timestamp,
|
|
};
|
|
}
|
|
|
|
// For same host, take the latest values (not sum)
|
|
// This handles cases where a host reports multiple times in the same time period
|
|
if (item.timestamp > acc[item.timeKey].max_timestamp) {
|
|
acc[item.timeKey].total_packages = item.total_packages;
|
|
acc[item.timeKey].packages_count = item.packages_count;
|
|
acc[item.timeKey].security_count = item.security_count;
|
|
acc[item.timeKey].max_timestamp = item.timestamp;
|
|
}
|
|
|
|
acc[item.timeKey].record_count += 1;
|
|
|
|
return acc;
|
|
}, {});
|
|
|
|
// Convert to array
|
|
aggregatedArray = Object.values(hostAggregatedData)
|
|
.map((item) => ({
|
|
...item,
|
|
host_count: item.host_ids.size,
|
|
host_ids: Array.from(item.host_ids),
|
|
}))
|
|
.sort((a, b) => a.timeKey.localeCompare(b.timeKey));
|
|
}
|
|
|
|
// Handle sparse data by filling missing time periods
|
|
const fillMissingPeriods = (data, daysInt) => {
|
|
const filledData = [];
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - daysInt);
|
|
|
|
const dataMap = new Map(data.map((item) => [item.timeKey, item]));
|
|
|
|
const endDate = new Date();
|
|
const currentDate = new Date(startDate);
|
|
|
|
// Find the last known values for interpolation
|
|
let lastKnownValues = null;
|
|
if (data.length > 0) {
|
|
lastKnownValues = {
|
|
total_packages: data[0].total_packages,
|
|
packages_count: data[0].packages_count,
|
|
security_count: data[0].security_count,
|
|
};
|
|
}
|
|
|
|
while (currentDate <= endDate) {
|
|
let timeKey;
|
|
if (daysInt <= 1) {
|
|
timeKey = currentDate.toISOString().substring(0, 13); // Hourly
|
|
currentDate.setHours(currentDate.getHours() + 1);
|
|
} else {
|
|
timeKey = currentDate.toISOString().split("T")[0]; // Daily
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
if (dataMap.has(timeKey)) {
|
|
const item = dataMap.get(timeKey);
|
|
filledData.push(item);
|
|
// Update last known values
|
|
lastKnownValues = {
|
|
total_packages: item.total_packages,
|
|
packages_count: item.packages_count,
|
|
security_count: item.security_count,
|
|
};
|
|
} else {
|
|
// For missing periods, use the last known values (interpolation)
|
|
// This creates a continuous line instead of gaps
|
|
filledData.push({
|
|
timeKey,
|
|
total_packages: lastKnownValues?.total_packages || 0,
|
|
packages_count: lastKnownValues?.packages_count || 0,
|
|
security_count: lastKnownValues?.security_count || 0,
|
|
record_count: 0,
|
|
host_count: 0,
|
|
host_ids: [],
|
|
min_timestamp: null,
|
|
max_timestamp: null,
|
|
isInterpolated: true, // Mark as interpolated for debugging
|
|
});
|
|
}
|
|
}
|
|
|
|
return filledData;
|
|
};
|
|
|
|
const finalProcessedData = fillMissingPeriods(aggregatedArray, daysInt);
|
|
|
|
// Get hosts list for dropdown
|
|
const hostsList = await prisma.hosts.findMany({
|
|
select: {
|
|
id: true,
|
|
friendly_name: true,
|
|
hostname: true,
|
|
last_update: true,
|
|
status: true,
|
|
},
|
|
orderBy: {
|
|
friendly_name: "asc",
|
|
},
|
|
});
|
|
|
|
// Get current package state for offline fallback
|
|
let currentPackageState = null;
|
|
if (hostId && hostId !== "all" && hostId !== "undefined") {
|
|
// Get current package counts for specific host
|
|
const currentState = await prisma.host_packages.aggregate({
|
|
where: {
|
|
host_id: hostId,
|
|
},
|
|
_count: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
// Get counts for boolean fields separately
|
|
const outdatedCount = await prisma.host_packages.count({
|
|
where: {
|
|
host_id: hostId,
|
|
needs_update: true,
|
|
},
|
|
});
|
|
|
|
const securityCount = await prisma.host_packages.count({
|
|
where: {
|
|
host_id: hostId,
|
|
is_security_update: true,
|
|
},
|
|
});
|
|
|
|
currentPackageState = {
|
|
total_packages: currentState._count.id,
|
|
packages_count: outdatedCount,
|
|
security_count: securityCount,
|
|
};
|
|
} else {
|
|
// Get current package counts for all hosts
|
|
// Total packages = count of unique packages installed on at least one host
|
|
const totalPackagesCount = await prisma.packages.count({
|
|
where: {
|
|
host_packages: {
|
|
some: {}, // At least one host has this package
|
|
},
|
|
},
|
|
});
|
|
|
|
// Get counts for boolean fields separately
|
|
const outdatedCount = await prisma.host_packages.count({
|
|
where: {
|
|
needs_update: true,
|
|
},
|
|
});
|
|
|
|
const securityCount = await prisma.host_packages.count({
|
|
where: {
|
|
is_security_update: true,
|
|
},
|
|
});
|
|
|
|
currentPackageState = {
|
|
total_packages: totalPackagesCount,
|
|
packages_count: outdatedCount,
|
|
security_count: securityCount,
|
|
};
|
|
}
|
|
|
|
// Format data for chart
|
|
const chartData = {
|
|
labels: [],
|
|
datasets: [
|
|
{
|
|
label: needsAggregation
|
|
? "Total Packages (All Hosts)"
|
|
: "Total Packages",
|
|
data: [],
|
|
borderColor: "#3B82F6", // Blue
|
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
|
tension: 0.4,
|
|
hidden: true, // Hidden by default
|
|
spanGaps: true, // Connect lines across missing data
|
|
pointRadius: 3,
|
|
pointHoverRadius: 5,
|
|
},
|
|
{
|
|
label: needsAggregation
|
|
? "Total Outdated Packages"
|
|
: "Outdated Packages",
|
|
data: [],
|
|
borderColor: "#F59E0B", // Orange
|
|
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
|
tension: 0.4,
|
|
spanGaps: true, // Connect lines across missing data
|
|
pointRadius: 3,
|
|
pointHoverRadius: 5,
|
|
},
|
|
{
|
|
label: needsAggregation
|
|
? "Total Security Packages"
|
|
: "Security Packages",
|
|
data: [],
|
|
borderColor: "#EF4444", // Red
|
|
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
|
tension: 0.4,
|
|
spanGaps: true, // Connect lines across missing data
|
|
pointRadius: 3,
|
|
pointHoverRadius: 5,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Process aggregated data
|
|
finalProcessedData.forEach((item) => {
|
|
chartData.labels.push(item.timeKey);
|
|
chartData.datasets[0].data.push(item.total_packages);
|
|
chartData.datasets[1].data.push(item.packages_count);
|
|
chartData.datasets[2].data.push(item.security_count);
|
|
});
|
|
|
|
// Calculate data quality metrics
|
|
const dataQuality = {
|
|
totalRecords: trendsData.length,
|
|
validRecords: processedData.length,
|
|
aggregatedPoints: aggregatedArray.length,
|
|
filledPoints: finalProcessedData.length,
|
|
recordsWithNullTotal: trendsData.filter(
|
|
(r) => r.total_packages === null,
|
|
).length,
|
|
recordsWithInvalidData: trendsData.length - processedData.length,
|
|
successfulReports: trendsData.filter((r) => r.status === "success")
|
|
.length,
|
|
failedReports: trendsData.filter((r) => r.status === "error").length,
|
|
};
|
|
|
|
res.json({
|
|
chartData,
|
|
hosts: hostsList,
|
|
period: daysInt,
|
|
hostId: hostId || "all",
|
|
currentPackageState,
|
|
dataQuality,
|
|
aggregationInfo: {
|
|
hasData: aggregatedArray.length > 0,
|
|
hasGaps: finalProcessedData.some((item) => item.record_count === 0),
|
|
lastDataPoint:
|
|
aggregatedArray.length > 0
|
|
? aggregatedArray[aggregatedArray.length - 1]
|
|
: null,
|
|
aggregationMode: needsAggregation
|
|
? "sum_across_hosts"
|
|
: "individual_host_data",
|
|
explanation: needsAggregation
|
|
? "Data is summed across all hosts for each time period"
|
|
: "Data shows individual host values without cross-host aggregation",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching package trends:", error);
|
|
res.status(500).json({ error: "Failed to fetch package trends" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Diagnostic endpoint to investigate package spikes
|
|
router.get(
|
|
"/package-spike-analysis",
|
|
authenticateToken,
|
|
requireViewHosts,
|
|
async (req, res) => {
|
|
try {
|
|
const { date, time, hours = 2 } = req.query;
|
|
|
|
if (!date || !time) {
|
|
return res.status(400).json({
|
|
error:
|
|
"Date and time parameters are required. Format: date=2025-10-17&time=18:00",
|
|
});
|
|
}
|
|
|
|
// Parse the specific date and time
|
|
const targetDateTime = new Date(`${date}T${time}:00`);
|
|
const startTime = new Date(targetDateTime);
|
|
startTime.setHours(startTime.getHours() - parseInt(hours, 10));
|
|
const endTime = new Date(targetDateTime);
|
|
endTime.setHours(endTime.getHours() + parseInt(hours, 10));
|
|
|
|
console.log(
|
|
`Analyzing package spike around ${targetDateTime.toISOString()}`,
|
|
);
|
|
console.log(
|
|
`Time range: ${startTime.toISOString()} to ${endTime.toISOString()}`,
|
|
);
|
|
|
|
// Get all update history records in the time window
|
|
const spikeData = await prisma.update_history.findMany({
|
|
where: {
|
|
timestamp: {
|
|
gte: startTime,
|
|
lte: endTime,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
host_id: true,
|
|
timestamp: true,
|
|
packages_count: true,
|
|
security_count: true,
|
|
total_packages: true,
|
|
status: true,
|
|
error_message: true,
|
|
execution_time: true,
|
|
payload_size_kb: true,
|
|
hosts: {
|
|
select: {
|
|
friendly_name: true,
|
|
hostname: true,
|
|
os_type: true,
|
|
os_version: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
timestamp: "asc",
|
|
},
|
|
});
|
|
|
|
// Analyze the data
|
|
const analysis = {
|
|
timeWindow: {
|
|
start: startTime.toISOString(),
|
|
end: endTime.toISOString(),
|
|
target: targetDateTime.toISOString(),
|
|
},
|
|
totalRecords: spikeData.length,
|
|
successfulReports: spikeData.filter((r) => r.status === "success")
|
|
.length,
|
|
failedReports: spikeData.filter((r) => r.status === "error").length,
|
|
uniqueHosts: [...new Set(spikeData.map((r) => r.host_id))].length,
|
|
hosts: {},
|
|
timeline: [],
|
|
summary: {
|
|
maxPackagesCount: 0,
|
|
maxSecurityCount: 0,
|
|
maxTotalPackages: 0,
|
|
avgPackagesCount: 0,
|
|
avgSecurityCount: 0,
|
|
avgTotalPackages: 0,
|
|
},
|
|
};
|
|
|
|
// Group by host and analyze each host's behavior
|
|
spikeData.forEach((record) => {
|
|
const hostId = record.host_id;
|
|
if (!analysis.hosts[hostId]) {
|
|
analysis.hosts[hostId] = {
|
|
hostInfo: record.hosts,
|
|
records: [],
|
|
summary: {
|
|
totalReports: 0,
|
|
successfulReports: 0,
|
|
failedReports: 0,
|
|
maxPackagesCount: 0,
|
|
maxSecurityCount: 0,
|
|
maxTotalPackages: 0,
|
|
avgPackagesCount: 0,
|
|
avgSecurityCount: 0,
|
|
avgTotalPackages: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
analysis.hosts[hostId].records.push({
|
|
timestamp: record.timestamp,
|
|
packages_count: record.packages_count,
|
|
security_count: record.security_count,
|
|
total_packages: record.total_packages,
|
|
status: record.status,
|
|
error_message: record.error_message,
|
|
execution_time: record.execution_time,
|
|
payload_size_kb: record.payload_size_kb,
|
|
});
|
|
|
|
analysis.hosts[hostId].summary.totalReports++;
|
|
if (record.status === "success") {
|
|
analysis.hosts[hostId].summary.successfulReports++;
|
|
analysis.hosts[hostId].summary.maxPackagesCount = Math.max(
|
|
analysis.hosts[hostId].summary.maxPackagesCount,
|
|
record.packages_count,
|
|
);
|
|
analysis.hosts[hostId].summary.maxSecurityCount = Math.max(
|
|
analysis.hosts[hostId].summary.maxSecurityCount,
|
|
record.security_count,
|
|
);
|
|
analysis.hosts[hostId].summary.maxTotalPackages = Math.max(
|
|
analysis.hosts[hostId].summary.maxTotalPackages,
|
|
record.total_packages || 0,
|
|
);
|
|
} else {
|
|
analysis.hosts[hostId].summary.failedReports++;
|
|
}
|
|
});
|
|
|
|
// Calculate averages for each host
|
|
Object.keys(analysis.hosts).forEach((hostId) => {
|
|
const host = analysis.hosts[hostId];
|
|
const successfulRecords = host.records.filter(
|
|
(r) => r.status === "success",
|
|
);
|
|
|
|
if (successfulRecords.length > 0) {
|
|
host.summary.avgPackagesCount = Math.round(
|
|
successfulRecords.reduce((sum, r) => sum + r.packages_count, 0) /
|
|
successfulRecords.length,
|
|
);
|
|
host.summary.avgSecurityCount = Math.round(
|
|
successfulRecords.reduce((sum, r) => sum + r.security_count, 0) /
|
|
successfulRecords.length,
|
|
);
|
|
host.summary.avgTotalPackages = Math.round(
|
|
successfulRecords.reduce(
|
|
(sum, r) => sum + (r.total_packages || 0),
|
|
0,
|
|
) / successfulRecords.length,
|
|
);
|
|
}
|
|
});
|
|
|
|
// Create timeline with hourly/daily aggregation
|
|
const timelineMap = new Map();
|
|
spikeData.forEach((record) => {
|
|
const timeKey = record.timestamp.toISOString().substring(0, 13); // Hourly
|
|
if (!timelineMap.has(timeKey)) {
|
|
timelineMap.set(timeKey, {
|
|
timestamp: timeKey,
|
|
totalReports: 0,
|
|
successfulReports: 0,
|
|
failedReports: 0,
|
|
totalPackagesCount: 0,
|
|
totalSecurityCount: 0,
|
|
totalTotalPackages: 0,
|
|
uniqueHosts: new Set(),
|
|
});
|
|
}
|
|
|
|
const timelineEntry = timelineMap.get(timeKey);
|
|
timelineEntry.totalReports++;
|
|
timelineEntry.uniqueHosts.add(record.host_id);
|
|
|
|
if (record.status === "success") {
|
|
timelineEntry.successfulReports++;
|
|
timelineEntry.totalPackagesCount += record.packages_count;
|
|
timelineEntry.totalSecurityCount += record.security_count;
|
|
timelineEntry.totalTotalPackages += record.total_packages || 0;
|
|
} else {
|
|
timelineEntry.failedReports++;
|
|
}
|
|
});
|
|
|
|
// Convert timeline map to array
|
|
analysis.timeline = Array.from(timelineMap.values())
|
|
.map((entry) => ({
|
|
...entry,
|
|
uniqueHosts: entry.uniqueHosts.size,
|
|
}))
|
|
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
|
|
// Calculate overall summary
|
|
const successfulRecords = spikeData.filter((r) => r.status === "success");
|
|
if (successfulRecords.length > 0) {
|
|
analysis.summary.maxPackagesCount = Math.max(
|
|
...successfulRecords.map((r) => r.packages_count),
|
|
);
|
|
analysis.summary.maxSecurityCount = Math.max(
|
|
...successfulRecords.map((r) => r.security_count),
|
|
);
|
|
analysis.summary.maxTotalPackages = Math.max(
|
|
...successfulRecords.map((r) => r.total_packages || 0),
|
|
);
|
|
analysis.summary.avgPackagesCount = Math.round(
|
|
successfulRecords.reduce((sum, r) => sum + r.packages_count, 0) /
|
|
successfulRecords.length,
|
|
);
|
|
analysis.summary.avgSecurityCount = Math.round(
|
|
successfulRecords.reduce((sum, r) => sum + r.security_count, 0) /
|
|
successfulRecords.length,
|
|
);
|
|
analysis.summary.avgTotalPackages = Math.round(
|
|
successfulRecords.reduce(
|
|
(sum, r) => sum + (r.total_packages || 0),
|
|
0,
|
|
) / successfulRecords.length,
|
|
);
|
|
}
|
|
|
|
// Identify potential causes of the spike
|
|
const potentialCauses = [];
|
|
|
|
// Check for hosts with unusually high package counts
|
|
Object.keys(analysis.hosts).forEach((hostId) => {
|
|
const host = analysis.hosts[hostId];
|
|
if (
|
|
host.summary.maxPackagesCount >
|
|
analysis.summary.avgPackagesCount * 2
|
|
) {
|
|
potentialCauses.push({
|
|
type: "high_package_count",
|
|
hostId,
|
|
hostName: host.hostInfo.friendly_name || host.hostInfo.hostname,
|
|
value: host.summary.maxPackagesCount,
|
|
avg: analysis.summary.avgPackagesCount,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for multiple hosts reporting at the same time (this explains the 500 vs 59 discrepancy)
|
|
const concurrentReports = analysis.timeline.filter(
|
|
(entry) => entry.uniqueHosts > 1,
|
|
);
|
|
if (concurrentReports.length > 0) {
|
|
potentialCauses.push({
|
|
type: "concurrent_reports",
|
|
description:
|
|
"Multiple hosts reported simultaneously - this explains why chart shows higher numbers than individual host reports",
|
|
count: concurrentReports.length,
|
|
details: concurrentReports.map((entry) => ({
|
|
timestamp: entry.timestamp,
|
|
totalPackagesCount: entry.totalPackagesCount,
|
|
uniqueHosts: entry.uniqueHosts,
|
|
avgPerHost: Math.round(
|
|
entry.totalPackagesCount / entry.uniqueHosts,
|
|
),
|
|
})),
|
|
explanation:
|
|
"The chart sums package counts across all hosts. If multiple hosts report at the same time, the chart shows the total sum, not individual host counts.",
|
|
});
|
|
}
|
|
|
|
// Check for failed reports that might indicate system issues
|
|
if (analysis.failedReports > 0) {
|
|
potentialCauses.push({
|
|
type: "failed_reports",
|
|
count: analysis.failedReports,
|
|
percentage: Math.round(
|
|
(analysis.failedReports / analysis.totalRecords) * 100,
|
|
),
|
|
});
|
|
}
|
|
|
|
// Add aggregation explanation
|
|
const aggregationExplanation = {
|
|
type: "aggregation_explanation",
|
|
description: "Chart Aggregation Logic",
|
|
details: {
|
|
howItWorks:
|
|
"The package trends chart sums package counts across all hosts for each time period",
|
|
individualHosts:
|
|
"Each host reports its own package count (e.g., 59 packages)",
|
|
chartDisplay:
|
|
"Chart shows the sum of all hosts' package counts (e.g., 59 + other hosts = 500)",
|
|
timeGrouping:
|
|
"Multiple hosts reporting in the same hour/day are aggregated together",
|
|
},
|
|
example: {
|
|
host1: "Host A reports 59 outdated packages",
|
|
host2: "Host B reports 120 outdated packages",
|
|
host3: "Host C reports 321 outdated packages",
|
|
chartShows: "Chart displays 500 total packages (59+120+321)",
|
|
},
|
|
};
|
|
potentialCauses.push(aggregationExplanation);
|
|
|
|
// Add specific host breakdown if a host ID is provided
|
|
let specificHostAnalysis = null;
|
|
if (req.query.hostId) {
|
|
const hostId = req.query.hostId;
|
|
const hostData = analysis.hosts[hostId];
|
|
if (hostData) {
|
|
specificHostAnalysis = {
|
|
hostId,
|
|
hostInfo: hostData.hostInfo,
|
|
summary: hostData.summary,
|
|
records: hostData.records,
|
|
explanation: `This host reported ${hostData.summary.maxPackagesCount} outdated packages, but the chart shows ${analysis.summary.maxPackagesCount} because it sums across all hosts that reported at the same time.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
analysis,
|
|
potentialCauses,
|
|
specificHostAnalysis,
|
|
recommendations: [
|
|
"Check if any hosts had major package updates around this time",
|
|
"Verify if any new hosts were added to the system",
|
|
"Check for system maintenance or updates that might have triggered package checks",
|
|
"Review any automation or scheduled tasks that run around 6pm",
|
|
"Check if any repositories were updated or new packages were released",
|
|
"Remember: Chart shows SUM of all hosts' package counts, not individual host counts",
|
|
],
|
|
});
|
|
} catch (error) {
|
|
console.error("Error analyzing package spike:", error);
|
|
res.status(500).json({ error: "Failed to analyze package spike" });
|
|
}
|
|
},
|
|
);
|
|
|
|
module.exports = router;
|