From b454b8d13039243acf3a4b4fdd6a301ab0559f0e Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:01:38 +0100 Subject: [PATCH] feat(packages): show all packages by default, add pagination --- backend/src/routes/packageRoutes.js | 18 +- frontend/src/pages/Packages.jsx | 296 ++++++++++++++++++++++------ 2 files changed, 244 insertions(+), 70 deletions(-) diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js index e611bd3..e948526 100644 --- a/backend/src/routes/packageRoutes.js +++ b/backend/src/routes/packageRoutes.js @@ -67,7 +67,9 @@ router.get("/", async (req, res) => { latest_version: true, created_at: true, _count: { - host_packages: true, + select: { + host_packages: true, + }, }, }, skip, @@ -82,7 +84,7 @@ router.get("/", async (req, res) => { // Get additional stats for each package const packagesWithStats = await Promise.all( packages.map(async (pkg) => { - const [updatesCount, securityCount, affectedHosts] = await Promise.all([ + const [updatesCount, securityCount, packageHosts] = await Promise.all([ prisma.host_packages.count({ where: { package_id: pkg.id, @@ -117,17 +119,17 @@ router.get("/", async (req, res) => { return { ...pkg, - affectedHostsCount: pkg._count.hostPackages, - affectedHosts: affectedHosts.map((hp) => ({ - hostId: hp.host.id, - friendlyName: hp.host.friendly_name, - osType: hp.host.os_type, + packageHostsCount: pkg._count.host_packages, + packageHosts: packageHosts.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, })), stats: { - totalInstalls: pkg._count.hostPackages, + totalInstalls: pkg._count.host_packages, updatesNeeded: updatesCount, securityUpdates: securityCount, }, diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx index ebb088d..75ca544 100644 --- a/frontend/src/pages/Packages.jsx +++ b/frontend/src/pages/Packages.jsx @@ -4,6 +4,8 @@ import { ArrowDown, ArrowUp, ArrowUpDown, + ChevronLeft, + ChevronRight, Columns, Eye as EyeIcon, EyeOff as EyeOffIcon, @@ -17,16 +19,28 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { dashboardAPI } from "../utils/api"; +import { dashboardAPI, packagesAPI } from "../utils/api"; const Packages = () => { const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState("all"); - const [securityFilter, setSecurityFilter] = useState("all"); + const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages"); const [hostFilter, setHostFilter] = useState("all"); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [showColumnSettings, setShowColumnSettings] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(() => { + const saved = localStorage.getItem("packages-page-size"); + if (saved) { + const parsedSize = parseInt(saved, 10); + // Validate that the saved page size is one of the allowed values + if ([25, 50, 100, 200].includes(parsedSize)) { + return parsedSize; + } + } + return 25; // Default fallback + }); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -42,8 +56,8 @@ const Packages = () => { const [columnConfig, setColumnConfig] = useState(() => { const defaultConfig = [ { id: "name", label: "Package", visible: true, order: 0 }, - { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, - { id: "priority", label: "Priority", visible: true, order: 2 }, + { id: "packageHosts", label: "Installed On", visible: true, order: 1 }, + { id: "status", label: "Status", visible: true, order: 2 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, ]; @@ -65,10 +79,10 @@ const Packages = () => { localStorage.setItem("packages-column-config", JSON.stringify(newConfig)); }; - // Handle affected hosts click - const handleAffectedHostsClick = (pkg) => { - const affectedHosts = pkg.affectedHosts || []; - const hostIds = affectedHosts.map((host) => host.hostId); + // Handle hosts click (view hosts where package is installed) + const handlePackageHostsClick = (pkg) => { + const packageHosts = pkg.packageHosts || []; + const hostIds = packageHosts.map((host) => host.hostId); // Create URL with selected hosts and filter const params = new URLSearchParams(); @@ -86,27 +100,43 @@ const Packages = () => { // For outdated packages, we want to show all packages that need updates // This is the default behavior, so we don't need to change filters setCategoryFilter("all"); - setSecurityFilter("all"); + setUpdateStatusFilter("needs-updates"); } else if (filter === "security") { // For security updates, filter to show only security updates - setSecurityFilter("security"); + setUpdateStatusFilter("security-updates"); setCategoryFilter("all"); } }, [searchParams]); const { - data: packages, + data: packagesResponse, isLoading, error, refetch, isFetching, } = useQuery({ queryKey: ["packages"], - queryFn: () => dashboardAPI.getPackages().then((res) => res.data), + queryFn: () => packagesAPI.getAll({ limit: 1000 }).then((res) => res.data), staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes refetchOnWindowFocus: false, // Don't refetch when window regains focus }); + // Extract packages from the response and normalise the data structure + const packages = useMemo(() => { + if (!packagesResponse?.packages) return []; + + return packagesResponse.packages.map((pkg) => ({ + ...pkg, + // Normalise field names to match the frontend expectations + packageHostsCount: pkg.packageHostsCount || pkg.stats?.totalInstalls || 0, + latestVersion: pkg.latest_version || pkg.latestVersion || "Unknown", + isUpdatable: (pkg.stats?.updatesNeeded || 0) > 0, + isSecurityUpdate: (pkg.stats?.securityUpdates || 0) > 0, + // Ensure we have hosts array (for packages, this contains all hosts where the package is installed) + packageHosts: pkg.packageHosts || [], + })); + }, [packagesResponse]); + // Fetch hosts data to get total packages count const { data: hosts } = useQuery({ queryKey: ["hosts"], @@ -128,17 +158,30 @@ const Packages = () => { const matchesCategory = categoryFilter === "all" || pkg.category === categoryFilter; - const matchesSecurity = - securityFilter === "all" || - (securityFilter === "security" && pkg.isSecurityUpdate) || - (securityFilter === "regular" && !pkg.isSecurityUpdate); + const matchesUpdateStatus = + updateStatusFilter === "all-packages" || + updateStatusFilter === "needs-updates" || + (updateStatusFilter === "security-updates" && pkg.isSecurityUpdate) || + (updateStatusFilter === "regular-updates" && !pkg.isSecurityUpdate); - const affectedHosts = pkg.affectedHosts || []; + // For "all-packages", we don't filter by update status + // For other filters, we only show packages that need updates + const matchesUpdateNeeded = + updateStatusFilter === "all-packages" || + (pkg.stats?.updatesNeeded || 0) > 0; + + const packageHosts = pkg.packageHosts || []; const matchesHost = hostFilter === "all" || - affectedHosts.some((host) => host.hostId === hostFilter); + packageHosts.some((host) => host.hostId === hostFilter); - return matchesSearch && matchesCategory && matchesSecurity && matchesHost; + return ( + matchesSearch && + matchesCategory && + matchesUpdateStatus && + matchesUpdateNeeded && + matchesHost + ); }); // Sorting @@ -154,14 +197,38 @@ const Packages = () => { aValue = a.latestVersion?.toLowerCase() || ""; bValue = b.latestVersion?.toLowerCase() || ""; break; - case "affectedHosts": - aValue = a.affectedHostsCount || a.affectedHosts?.length || 0; - bValue = b.affectedHostsCount || b.affectedHosts?.length || 0; + case "packageHosts": + aValue = a.packageHostsCount || a.packageHosts?.length || 0; + bValue = b.packageHostsCount || b.packageHosts?.length || 0; break; - case "priority": - aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first - bValue = b.isSecurityUpdate ? 0 : 1; + case "status": { + // Handle sorting for the three status states: Up to Date, Update Available, Security Update Available + const aNeedsUpdates = (a.stats?.updatesNeeded || 0) > 0; + const bNeedsUpdates = (b.stats?.updatesNeeded || 0) > 0; + + // Define priority order: Security Update (0) > Regular Update (1) > Up to Date (2) + let aPriority, bPriority; + + if (!aNeedsUpdates) { + aPriority = 2; // Up to Date + } else if (a.isSecurityUpdate) { + aPriority = 0; // Security Update + } else { + aPriority = 1; // Regular Update + } + + if (!bNeedsUpdates) { + bPriority = 2; // Up to Date + } else if (b.isSecurityUpdate) { + bPriority = 0; // Security Update + } else { + bPriority = 1; // Regular Update + } + + aValue = aPriority; + bValue = bPriority; break; + } default: aValue = a.name?.toLowerCase() || ""; bValue = b.name?.toLowerCase() || ""; @@ -177,12 +244,33 @@ const Packages = () => { packages, searchTerm, categoryFilter, - securityFilter, + updateStatusFilter, sortField, sortDirection, hostFilter, ]); + // Calculate pagination + const totalPages = Math.ceil(filteredAndSortedPackages.length / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedPackages = filteredAndSortedPackages.slice( + startIndex, + endIndex, + ); + + // Reset to first page when filters or page size change + // biome-ignore lint/correctness/useExhaustiveDependencies: We want this effect to run when filter values or page size change to reset pagination + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, categoryFilter, updateStatusFilter, hostFilter, pageSize]); + + // Function to handle page size change and save to localStorage + const handlePageSizeChange = (newPageSize) => { + setPageSize(newPageSize); + localStorage.setItem("packages-page-size", newPageSize.toString()); + }; + // Get visible columns in order const visibleColumns = columnConfig .filter((col) => col.visible) @@ -231,8 +319,8 @@ const Packages = () => { const resetColumns = () => { const defaultConfig = [ { id: "name", label: "Package", visible: true, order: 0 }, - { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, - { id: "priority", label: "Priority", visible: true, order: 2 }, + { id: "packageHosts", label: "Installed On", visible: true, order: 1 }, + { id: "status", label: "Status", visible: true, order: 2 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, ]; updateColumnConfig(defaultConfig); @@ -262,31 +350,56 @@ const Packages = () => { ); - case "affectedHosts": { - const affectedHostsCount = - pkg.affectedHostsCount || pkg.affectedHosts?.length || 0; + case "packageHosts": { + // Show total number of hosts where this package is installed + const installedHostsCount = + pkg.packageHostsCount || + pkg.stats?.totalInstalls || + pkg.packageHosts?.length || + 0; + // For packages that need updates, show how many need updates + const hostsNeedingUpdates = pkg.stats?.updatesNeeded || 0; + + const displayText = + hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount + ? `${hostsNeedingUpdates}/${installedHostsCount} hosts` + : `${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`; + + const titleText = + hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount + ? `${hostsNeedingUpdates} of ${installedHostsCount} hosts need updates` + : `Installed on ${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`; + return ( ); } - case "priority": + case "status": { + // Check if this package needs updates + const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0; + + if (!needsUpdates) { + return Up to Date; + } + return pkg.isSecurityUpdate ? ( - Security Update + Security Update Available ) : ( - Regular Update + Update Available ); + } case "latestVersion": return (
{ const categories = [...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || []; - // Calculate unique affected hosts - const uniqueAffectedHosts = new Set(); + // Calculate unique package hosts + const uniquePackageHosts = new Set(); packages?.forEach((pkg) => { - const affectedHosts = pkg.affectedHosts || []; - affectedHosts.forEach((host) => { - uniqueAffectedHosts.add(host.hostId); - }); + // Only count hosts for packages that need updates + if ((pkg.stats?.updatesNeeded || 0) > 0) { + const packageHosts = pkg.packageHosts || []; + packageHosts.forEach((host) => { + uniquePackageHosts.add(host.hostId); + }); + } }); - const uniqueAffectedHostsCount = uniqueAffectedHosts.size; + const uniquePackageHostsCount = uniquePackageHosts.size; - // Calculate total packages across all hosts (including up-to-date ones) - const totalPackagesCount = - hosts?.reduce((total, host) => { - return total + (host.totalPackagesCount || 0); - }, 0) || 0; + // Calculate total packages available + const totalPackagesCount = packages?.length || 0; - // Calculate outdated packages (packages that need updates) - const outdatedPackagesCount = packages?.length || 0; + // Calculate outdated packages + const outdatedPackagesCount = + packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0; // Calculate security updates const securityUpdatesCount = - packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0; + packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length || + 0; if (isLoading) { return ( @@ -429,7 +544,7 @@ const Packages = () => { Hosts Pending Updates

- {uniqueAffectedHostsCount} + {uniquePackageHostsCount}

@@ -490,16 +605,21 @@ const Packages = () => { - {/* Security Filter */} + {/* Update Status Filter */}
@@ -539,12 +659,13 @@ const Packages = () => {

{packages?.length === 0 - ? "No packages need updates" + ? "No packages found" : "No packages match your filters"}

{packages?.length === 0 && (

- All packages are up to date across all hosts + Packages will appear here once hosts start reporting their + installed packages

)} @@ -571,7 +692,7 @@ const Packages = () => { - {filteredAndSortedPackages.map((pkg) => ( + {paginatedPackages.map((pkg) => ( { )} + + {/* Pagination Controls */} + {filteredAndSortedPackages.length > 0 && ( +
+
+
+ + Rows per page: + + +
+ + {startIndex + 1}- + {Math.min(endIndex, filteredAndSortedPackages.length)} of{" "} + {filteredAndSortedPackages.length} + +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )}