feat(packages): show all packages by default, add pagination

This commit is contained in:
tigattack
2025-10-01 02:01:38 +01:00
parent 9ddc27e50c
commit b454b8d130
2 changed files with 244 additions and 70 deletions

View File

@@ -67,9 +67,11 @@ router.get("/", async (req, res) => {
latest_version: true, latest_version: true,
created_at: true, created_at: true,
_count: { _count: {
select: {
host_packages: true, host_packages: true,
}, },
}, },
},
skip, skip,
take, take,
orderBy: { orderBy: {
@@ -82,7 +84,7 @@ router.get("/", async (req, res) => {
// Get additional stats for each package // Get additional stats for each package
const packagesWithStats = await Promise.all( const packagesWithStats = await Promise.all(
packages.map(async (pkg) => { packages.map(async (pkg) => {
const [updatesCount, securityCount, affectedHosts] = await Promise.all([ const [updatesCount, securityCount, packageHosts] = await Promise.all([
prisma.host_packages.count({ prisma.host_packages.count({
where: { where: {
package_id: pkg.id, package_id: pkg.id,
@@ -117,17 +119,17 @@ router.get("/", async (req, res) => {
return { return {
...pkg, ...pkg,
affectedHostsCount: pkg._count.hostPackages, packageHostsCount: pkg._count.host_packages,
affectedHosts: affectedHosts.map((hp) => ({ packageHosts: packageHosts.map((hp) => ({
hostId: hp.host.id, hostId: hp.hosts.id,
friendlyName: hp.host.friendly_name, friendlyName: hp.hosts.friendly_name,
osType: hp.host.os_type, osType: hp.hosts.os_type,
currentVersion: hp.current_version, currentVersion: hp.current_version,
availableVersion: hp.available_version, availableVersion: hp.available_version,
isSecurityUpdate: hp.is_security_update, isSecurityUpdate: hp.is_security_update,
})), })),
stats: { stats: {
totalInstalls: pkg._count.hostPackages, totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount, updatesNeeded: updatesCount,
securityUpdates: securityCount, securityUpdates: securityCount,
}, },

View File

@@ -4,6 +4,8 @@ import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
ChevronLeft,
ChevronRight,
Columns, Columns,
Eye as EyeIcon, Eye as EyeIcon,
EyeOff as EyeOffIcon, EyeOff as EyeOffIcon,
@@ -17,16 +19,28 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { dashboardAPI } from "../utils/api"; import { dashboardAPI, packagesAPI } from "../utils/api";
const Packages = () => { const Packages = () => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all");
const [securityFilter, setSecurityFilter] = useState("all"); const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages");
const [hostFilter, setHostFilter] = useState("all"); const [hostFilter, setHostFilter] = useState("all");
const [sortField, setSortField] = useState("name"); const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc"); const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false); 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 [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -42,8 +56,8 @@ const Packages = () => {
const [columnConfig, setColumnConfig] = useState(() => { const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [ const defaultConfig = [
{ id: "name", label: "Package", visible: true, order: 0 }, { id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, { id: "packageHosts", label: "Installed On", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 }, { id: "status", label: "Status", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
]; ];
@@ -65,10 +79,10 @@ const Packages = () => {
localStorage.setItem("packages-column-config", JSON.stringify(newConfig)); localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
}; };
// Handle affected hosts click // Handle hosts click (view hosts where package is installed)
const handleAffectedHostsClick = (pkg) => { const handlePackageHostsClick = (pkg) => {
const affectedHosts = pkg.affectedHosts || []; const packageHosts = pkg.packageHosts || [];
const hostIds = affectedHosts.map((host) => host.hostId); const hostIds = packageHosts.map((host) => host.hostId);
// Create URL with selected hosts and filter // Create URL with selected hosts and filter
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -86,27 +100,43 @@ const Packages = () => {
// For outdated packages, we want to show all packages that need updates // 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 // This is the default behavior, so we don't need to change filters
setCategoryFilter("all"); setCategoryFilter("all");
setSecurityFilter("all"); setUpdateStatusFilter("needs-updates");
} else if (filter === "security") { } else if (filter === "security") {
// For security updates, filter to show only security updates // For security updates, filter to show only security updates
setSecurityFilter("security"); setUpdateStatusFilter("security-updates");
setCategoryFilter("all"); setCategoryFilter("all");
} }
}, [searchParams]); }, [searchParams]);
const { const {
data: packages, data: packagesResponse,
isLoading, isLoading,
error, error,
refetch, refetch,
isFetching, isFetching,
} = useQuery({ } = useQuery({
queryKey: ["packages"], 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 staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus 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 // Fetch hosts data to get total packages count
const { data: hosts } = useQuery({ const { data: hosts } = useQuery({
queryKey: ["hosts"], queryKey: ["hosts"],
@@ -128,17 +158,30 @@ const Packages = () => {
const matchesCategory = const matchesCategory =
categoryFilter === "all" || pkg.category === categoryFilter; categoryFilter === "all" || pkg.category === categoryFilter;
const matchesSecurity = const matchesUpdateStatus =
securityFilter === "all" || updateStatusFilter === "all-packages" ||
(securityFilter === "security" && pkg.isSecurityUpdate) || updateStatusFilter === "needs-updates" ||
(securityFilter === "regular" && !pkg.isSecurityUpdate); (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 = const matchesHost =
hostFilter === "all" || 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 // Sorting
@@ -154,14 +197,38 @@ const Packages = () => {
aValue = a.latestVersion?.toLowerCase() || ""; aValue = a.latestVersion?.toLowerCase() || "";
bValue = b.latestVersion?.toLowerCase() || ""; bValue = b.latestVersion?.toLowerCase() || "";
break; break;
case "affectedHosts": case "packageHosts":
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0; aValue = a.packageHostsCount || a.packageHosts?.length || 0;
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0; bValue = b.packageHostsCount || b.packageHosts?.length || 0;
break; break;
case "priority": case "status": {
aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first // Handle sorting for the three status states: Up to Date, Update Available, Security Update Available
bValue = b.isSecurityUpdate ? 0 : 1; 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; break;
}
default: default:
aValue = a.name?.toLowerCase() || ""; aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || ""; bValue = b.name?.toLowerCase() || "";
@@ -177,12 +244,33 @@ const Packages = () => {
packages, packages,
searchTerm, searchTerm,
categoryFilter, categoryFilter,
securityFilter, updateStatusFilter,
sortField, sortField,
sortDirection, sortDirection,
hostFilter, 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 // Get visible columns in order
const visibleColumns = columnConfig const visibleColumns = columnConfig
.filter((col) => col.visible) .filter((col) => col.visible)
@@ -231,8 +319,8 @@ const Packages = () => {
const resetColumns = () => { const resetColumns = () => {
const defaultConfig = [ const defaultConfig = [
{ id: "name", label: "Package", visible: true, order: 0 }, { id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, { id: "packageHosts", label: "Installed On", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 }, { id: "status", label: "Status", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
]; ];
updateColumnConfig(defaultConfig); updateColumnConfig(defaultConfig);
@@ -262,31 +350,56 @@ const Packages = () => {
</div> </div>
</div> </div>
); );
case "affectedHosts": { case "packageHosts": {
const affectedHostsCount = // Show total number of hosts where this package is installed
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0; 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 ( return (
<button <button
type="button" type="button"
onClick={() => handleAffectedHostsClick(pkg)} onClick={() => handlePackageHostsClick(pkg)}
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group" className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
title={`Click to view all ${affectedHostsCount} affected hosts`} title={titleText}
> >
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400"> <div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""} {displayText}
</div> </div>
</button> </button>
); );
} }
case "priority": case "status": {
// Check if this package needs updates
const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0;
if (!needsUpdates) {
return <span className="badge-success">Up to Date</span>;
}
return pkg.isSecurityUpdate ? ( return pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1"> <span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
Security Update Security Update Available
</span> </span>
) : ( ) : (
<span className="badge-warning">Regular Update</span> <span className="badge-warning">Update Available</span>
); );
}
case "latestVersion": case "latestVersion":
return ( return (
<div <div
@@ -305,28 +418,30 @@ const Packages = () => {
const categories = const categories =
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || []; [...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
// Calculate unique affected hosts // Calculate unique package hosts
const uniqueAffectedHosts = new Set(); const uniquePackageHosts = new Set();
packages?.forEach((pkg) => { packages?.forEach((pkg) => {
const affectedHosts = pkg.affectedHosts || []; // Only count hosts for packages that need updates
affectedHosts.forEach((host) => { if ((pkg.stats?.updatesNeeded || 0) > 0) {
uniqueAffectedHosts.add(host.hostId); 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) // Calculate total packages available
const totalPackagesCount = const totalPackagesCount = packages?.length || 0;
hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0);
}, 0) || 0;
// Calculate outdated packages (packages that need updates) // Calculate outdated packages
const outdatedPackagesCount = packages?.length || 0; const outdatedPackagesCount =
packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0;
// Calculate security updates // Calculate security updates
const securityUpdatesCount = const securityUpdatesCount =
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0; packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length ||
0;
if (isLoading) { if (isLoading) {
return ( return (
@@ -429,7 +544,7 @@ const Packages = () => {
Hosts Pending Updates Hosts Pending Updates
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount} {uniquePackageHostsCount}
</p> </p>
</div> </div>
</div> </div>
@@ -490,16 +605,21 @@ const Packages = () => {
</select> </select>
</div> </div>
{/* Security Filter */} {/* Update Status Filter */}
<div className="sm:w-48"> <div className="sm:w-48">
<select <select
value={securityFilter} value={updateStatusFilter}
onChange={(e) => setSecurityFilter(e.target.value)} onChange={(e) => setUpdateStatusFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white" className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
> >
<option value="all">All Updates</option> <option value="all-packages">All Packages</option>
<option value="security">Security Only</option> <option value="needs-updates">
<option value="regular">Regular Only</option> Packages Needing Updates
</option>
<option value="security-updates">
Security Updates Only
</option>
<option value="regular-updates">Regular Updates Only</option>
</select> </select>
</div> </div>
@@ -539,12 +659,13 @@ const Packages = () => {
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> <Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300"> <p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 {packages?.length === 0
? "No packages need updates" ? "No packages found"
: "No packages match your filters"} : "No packages match your filters"}
</p> </p>
{packages?.length === 0 && ( {packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2"> <p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts Packages will appear here once hosts start reporting their
installed packages
</p> </p>
)} )}
</div> </div>
@@ -571,7 +692,7 @@ const Packages = () => {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> <tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedPackages.map((pkg) => ( {paginatedPackages.map((pkg) => (
<tr <tr
key={pkg.id} key={pkg.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
@@ -591,6 +712,57 @@ const Packages = () => {
</div> </div>
)} )}
</div> </div>
{/* Pagination Controls */}
{filteredAndSortedPackages.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Rows per page:
</span>
<select
value={pageSize}
onChange={(e) =>
handlePageSizeChange(Number(e.target.value))
}
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
{startIndex + 1}-
{Math.min(endIndex, filteredAndSortedPackages.length)} of{" "}
{filteredAndSortedPackages.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div> </div>
</div> </div>