Merge pull request #123 from PatchMon/feat/package_detail

feat: add package detail page and list all packages with pagination
This commit is contained in:
9 Technology Group LTD
2025-10-03 18:03:57 +01:00
committed by GitHub
3 changed files with 837 additions and 100 deletions

View File

@@ -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,18 @@ 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,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
})),
stats: {
totalInstalls: pkg._count.hostPackages,
totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
},
@@ -160,19 +163,19 @@ router.get("/:packageId", async (req, res) => {
include: {
host_packages: {
include: {
host: {
hosts: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
os_type: true,
os_version: true,
last_update: true,
},
},
},
orderBy: {
needsUpdate: "desc",
needs_update: "desc",
},
},
},
@@ -185,25 +188,25 @@ router.get("/:packageId", async (req, res) => {
// Calculate statistics
const stats = {
totalInstalls: packageData.host_packages.length,
updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate)
updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update)
.length,
securityUpdates: packageData.host_packages.filter(
(hp) => hp.needsUpdate && hp.isSecurityUpdate,
(hp) => hp.needs_update && hp.is_security_update,
).length,
upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate)
upToDate: packageData.host_packages.filter((hp) => !hp.needs_update)
.length,
};
// Group by version
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
const version = hp.currentVersion;
const version = hp.current_version;
acc[version] = (acc[version] || 0) + 1;
return acc;
}, {});
// Group by OS type
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
const osType = hp.host.osType;
const osType = hp.hosts.os_type;
acc[osType] = (acc[osType] || 0) + 1;
return acc;
}, {});
@@ -230,4 +233,109 @@ router.get("/:packageId", async (req, res) => {
}
});
// Get hosts where a package is installed
router.get("/:packageId/hosts", async (req, res) => {
try {
const { packageId } = req.params;
const {
page = 1,
limit = 25,
search = "",
sortBy = "friendly_name",
sortOrder = "asc",
} = req.query;
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
// Build search conditions
const searchConditions = search
? {
OR: [
{
hosts: {
friendly_name: { contains: search, mode: "insensitive" },
},
},
{ hosts: { hostname: { contains: search, mode: "insensitive" } } },
{ current_version: { contains: search, mode: "insensitive" } },
{ available_version: { contains: search, mode: "insensitive" } },
],
}
: {};
// Build sort conditions
const orderBy = {};
if (
sortBy === "friendly_name" ||
sortBy === "hostname" ||
sortBy === "os_type"
) {
orderBy.hosts = { [sortBy]: sortOrder };
} else if (sortBy === "needs_update") {
orderBy[sortBy] = sortOrder;
} else {
orderBy[sortBy] = sortOrder;
}
// Get total count
const totalCount = await prisma.host_packages.count({
where: {
package_id: packageId,
...searchConditions,
},
});
// Get paginated results
const hostPackages = await prisma.host_packages.findMany({
where: {
package_id: packageId,
...searchConditions,
},
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
os_version: true,
last_update: true,
},
},
},
orderBy,
skip: offset,
take: parseInt(limit, 10),
});
// Transform the data for the frontend
const hosts = hostPackages.map((hp) => ({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
hostname: hp.hosts.hostname,
osType: hp.hosts.os_type,
osVersion: hp.hosts.os_version,
lastUpdate: hp.hosts.last_update,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
lastChecked: hp.last_checked,
}));
res.json({
hosts,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total: totalCount,
pages: Math.ceil(totalCount / parseInt(limit, 10)),
},
});
} catch (error) {
console.error("Error fetching package hosts:", error);
res.status(500).json({ error: "Failed to fetch package hosts" });
}
});
module.exports = router;

View File

@@ -1,23 +1,476 @@
import { Package } from "lucide-react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Calendar,
ChartColumnBig,
ChevronRight,
Download,
Package,
RefreshCw,
Search,
Server,
Shield,
Tag,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { formatRelativeTime, packagesAPI } from "../utils/api";
const PackageDetail = () => {
const { packageId } = useParams();
const decodedPackageId = decodeURIComponent(packageId || "");
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Fetch package details
const {
data: packageData,
isLoading: isLoadingPackage,
error: packageError,
refetch: refetchPackage,
} = useQuery({
queryKey: ["package", decodedPackageId],
queryFn: () =>
packagesAPI.getById(decodedPackageId).then((res) => res.data),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!decodedPackageId,
});
// Fetch hosts that have this package
const {
data: hostsData,
isLoading: isLoadingHosts,
error: hostsError,
refetch: refetchHosts,
} = useQuery({
queryKey: ["package-hosts", decodedPackageId, searchTerm],
queryFn: () =>
packagesAPI
.getHosts(decodedPackageId, { search: searchTerm, limit: 1000 })
.then((res) => res.data),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!decodedPackageId,
});
const hosts = hostsData?.hosts || [];
// Filter and paginate hosts
const filteredAndPaginatedHosts = useMemo(() => {
let filtered = hosts;
if (searchTerm) {
filtered = hosts.filter(
(host) =>
host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return filtered.slice(startIndex, endIndex);
}, [hosts, searchTerm, currentPage, pageSize]);
const totalPages = Math.ceil(
(searchTerm
? hosts.filter(
(host) =>
host.friendlyName
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
).length
: hosts.length) / pageSize,
);
const handleHostClick = (hostId) => {
navigate(`/hosts/${hostId}`);
};
const handleRefresh = () => {
refetchPackage();
refetchHosts();
};
if (isLoadingPackage) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
);
}
if (packageError) {
return (
<div className="space-y-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading package
</h3>
<p className="text-sm text-danger-700 mt-1">
{packageError.message || "Failed to load package details"}
</p>
<button
type="button"
onClick={() => refetchPackage()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
</div>
);
}
if (!packageData) {
return (
<div className="space-y-6">
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
Package not found
</p>
</div>
</div>
);
}
const pkg = packageData;
const stats = packageData.stats || {};
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">
Package Details
</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version
distribution, and more.
</p>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => navigate("/packages")}
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to Packages
</button>
<ChevronRight className="h-4 w-4 text-secondary-400" />
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
{pkg.name}
</h1>
</div>
<button
type="button"
onClick={handleRefresh}
disabled={isLoadingPackage || isLoadingHosts}
className="btn-outline flex items-center gap-2"
>
<RefreshCw
className={`h-4 w-4 ${
isLoadingPackage || isLoadingHosts ? "animate-spin" : ""
}`}
/>
Refresh
</button>
</div>
{/* Package Overview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Package Info */}
<div className="lg:col-span-2">
<div className="card p-6">
<div className="flex items-start gap-4 mb-4">
<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
{pkg.name}
</h2>
{pkg.description && (
<p className="text-secondary-600 dark:text-secondary-300 mb-4">
{pkg.description}
</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
{pkg.category && (
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Category: {pkg.category}
</span>
</div>
)}
{pkg.latest_version && (
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Latest: {pkg.latest_version}
</span>
</div>
)}
{pkg.updated_at && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Updated: {formatRelativeTime(pkg.updated_at)}
</span>
</div>
)}
</div>
</div>
</div>
{/* Status Badge */}
<div className="mb-4">
{stats.updatesNeeded > 0 ? (
stats.securityUpdates > 0 ? (
<span className="badge-danger flex items-center gap-1 w-fit">
<Shield className="h-3 w-3" />
Security Update Available
</span>
) : (
<span className="badge-warning w-fit">Update Available</span>
)
) : (
<span className="badge-success w-fit">Up to Date</span>
)}
</div>
</div>
</div>
{/* Statistics */}
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center gap-3 mb-3">
<ChartColumnBig className="h-5 w-5 text-primary-600" />
<h3 className="font-medium text-secondary-900 dark:text-white">
Installation Stats
</h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Total Installations
</span>
<span className="font-semibold text-secondary-900 dark:text-white">
{stats.totalInstalls || 0}
</span>
</div>
{stats.updatesNeeded > 0 && (
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Hosts Needing Updates
</span>
<span className="font-semibold text-warning-600">
{stats.updatesNeeded}
</span>
</div>
)}
{stats.securityUpdates > 0 && (
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Security Updates
</span>
<span className="font-semibold text-danger-600">
{stats.securityUpdates}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Up to Date
</span>
<span className="font-semibold text-success-600">
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Hosts List */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Installed On Hosts ({hosts.length})
</h3>
</div>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
<input
type="text"
placeholder="Search hosts..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-4 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 placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingHosts ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
</div>
) : hostsError ? (
<div className="p-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading hosts
</h3>
<p className="text-sm text-danger-700 mt-1">
{hostsError.message || "Failed to load hosts"}
</p>
</div>
</div>
</div>
</div>
) : filteredAndPaginatedHosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{searchTerm
? "No hosts match your search"
: "No hosts have this package installed"}
</p>
</div>
) : (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Current Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Updated
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndPaginatedHosts.map((host) => (
<tr
key={host.hostId}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
onClick={() => handleHostClick(host.hostId)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Server className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{host.friendlyName || host.hostname}
</div>
{host.friendlyName && host.hostname && (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{host.hostname}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{host.currentVersion || "Unknown"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{host.needsUpdate ? (
host.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1 w-fit">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning w-fit">
Update Available
</span>
)
) : (
<span className="badge-success w-fit">
Up to Date
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{host.lastUpdate
? formatRelativeTime(host.lastUpdate)
: "Never"}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
<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) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
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>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Previous
</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="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);

View File

@@ -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);
@@ -243,10 +331,14 @@ const Packages = () => {
switch (column.id) {
case "name":
return (
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
</div>
{pkg.description && (
@@ -260,33 +352,58 @@ const Packages = () => {
</div>
)}
</div>
</div>
</button>
);
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 (
<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"
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">
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
{displayText}
</div>
</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 ? (
<span className="badge-danger flex items-center gap-1">
<span className="badge-danger">
<Shield className="h-3 w-3" />
Security Update
Security Update Available
</span>
) : (
<span className="badge-warning">Regular Update</span>
<span className="badge-warning">Update Available</span>
);
}
case "latestVersion":
return (
<div
@@ -305,28 +422,30 @@ const Packages = () => {
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 +548,7 @@ const Packages = () => {
Hosts Pending Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
{uniquePackageHostsCount}
</p>
</div>
</div>
@@ -490,16 +609,21 @@ const Packages = () => {
</select>
</div>
{/* Security Filter */}
{/* Update Status Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
value={updateStatusFilter}
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"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
<option value="all-packages">All Packages</option>
<option value="needs-updates">
Packages Needing Updates
</option>
<option value="security-updates">
Security Updates Only
</option>
<option value="regular-updates">Regular Updates Only</option>
</select>
</div>
@@ -539,12 +663,13 @@ const Packages = () => {
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0
? "No packages need updates"
? "No packages found"
: "No packages match your filters"}
</p>
{packages?.length === 0 && (
<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>
)}
</div>
@@ -571,7 +696,7 @@ const Packages = () => {
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedPackages.map((pkg) => (
{paginatedPackages.map((pkg) => (
<tr
key={pkg.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
@@ -591,6 +716,57 @@ const Packages = () => {
</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>