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 1/7] 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}
+
+
+
+
+ )}
From 8bb16f08966fa666d938245456c041be0dd4f2e9 Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Wed, 1 Oct 2025 21:59:45 +0100
Subject: [PATCH 2/7] fix(api): update package host fields to match database
schema
---
backend/src/routes/packageRoutes.js | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js
index e948526..6a7a54b 100644
--- a/backend/src/routes/packageRoutes.js
+++ b/backend/src/routes/packageRoutes.js
@@ -162,19 +162,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",
},
},
},
@@ -187,25 +187,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;
}, {});
From 6f59a1981d0f35e1951d3a0b3e3d3a1831eef02d Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:01:45 +0100
Subject: [PATCH 3/7] feat(api): endpoint to retrieve hosts for a pkg
With pagination and search functionality
---
backend/src/routes/packageRoutes.js | 105 ++++++++++++++++++++++++++++
1 file changed, 105 insertions(+)
diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js
index 6a7a54b..4188b06 100644
--- a/backend/src/routes/packageRoutes.js
+++ b/backend/src/routes/packageRoutes.js
@@ -232,4 +232,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;
From fffc571453c31805e471631a0a1cf25f030b1d5b Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:02:15 +0100
Subject: [PATCH 4/7] feat(packages): complete package detail page
Open by clicking package name
---
frontend/src/pages/PackageDetail.jsx | 483 ++++++++++++++++++++++++++-
frontend/src/pages/Packages.jsx | 14 +-
2 files changed, 478 insertions(+), 19 deletions(-)
diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx
index a1183c7..8eb1abe 100644
--- a/frontend/src/pages/PackageDetail.jsx
+++ b/frontend/src/pages/PackageDetail.jsx
@@ -1,23 +1,478 @@
-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.friendly_name
+ ?.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.friendly_name
+ ?.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 (
+
+
+
+ );
+ }
+
+ if (packageError) {
+ return (
+
+
+
+
+
+
+ Error loading package
+
+
+ {packageError.message || "Failed to load package details"}
+
+
+
+
+
+
+ );
+ }
+
+ if (!packageData) {
+ return (
+
+
+
+
+ Package not found
+
+
+
+ );
+ }
+
+ const pkg = packageData;
+ const stats = packageData.stats || {};
return (
-
-
-
- Package Details
-
-
- Detailed view for package: {packageId}
-
-
- This page will show package information, affected hosts, version
- distribution, and more.
-
+ {/* Header */}
+
+
+
+
+
+ {pkg.name}
+
+
+
+
+
+ {/* Package Overview */}
+
+ {/* Main Package Info */}
+
+
+
+
+
+
+ {pkg.name}
+
+ {pkg.description && (
+
+ {pkg.description}
+
+ )}
+
+ {pkg.category && (
+
+
+
+ Category: {pkg.category}
+
+
+ )}
+ {pkg.latest_version && (
+
+
+
+ Latest: {pkg.latest_version}
+
+
+ )}
+ {pkg.updated_at && (
+
+
+
+ Updated: {formatRelativeTime(pkg.updated_at)}
+
+
+ )}
+
+
+
+
+ {/* Status Badge */}
+
+ {stats.updatesNeeded > 0 ? (
+ stats.securityUpdates > 0 ? (
+
+
+ Security Update Available
+
+ ) : (
+ Update Available
+ )
+ ) : (
+ Up to Date
+ )}
+
+
+
+
+ {/* Statistics */}
+
+
+
+
+
+ Installation Stats
+
+
+
+
+
+ Total Installations
+
+
+ {stats.totalInstalls || 0}
+
+
+ {stats.updatesNeeded > 0 && (
+
+
+ Hosts Needing Updates
+
+
+ {stats.updatesNeeded}
+
+
+ )}
+ {stats.securityUpdates > 0 && (
+
+
+ Security Updates
+
+
+ {stats.securityUpdates}
+
+
+ )}
+
+
+ Up to Date
+
+
+ {(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
+
+
+
+
+
+
+
+ {/* Hosts List */}
+
+
+
+
+
+
+ Installed On Hosts ({hosts.length})
+
+
+
+
+ {/* Search */}
+
+
+ {
+ 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"
+ />
+
+
+
+
+ {isLoadingHosts ? (
+
+
+
+ ) : hostsError ? (
+
+
+
+
+
+
+ Error loading hosts
+
+
+ {hostsError.message || "Failed to load hosts"}
+
+
+
+
+
+ ) : filteredAndPaginatedHosts.length === 0 ? (
+
+
+
+ {searchTerm
+ ? "No hosts match your search"
+ : "No hosts have this package installed"}
+
+
+ ) : (
+ <>
+
+
+
+
+ Host
+ |
+
+ Current Version
+ |
+
+ Status
+ |
+
+ Last Updated
+ |
+
+
+
+ {filteredAndPaginatedHosts.map((host) => (
+ handleHostClick(host.id)}
+ >
+
+
+
+
+
+ {host.friendly_name || host.hostname}
+
+ {host.friendly_name && host.hostname && (
+
+ {host.hostname}
+
+ )}
+
+
+ |
+
+ {host.current_version || "Unknown"}
+ |
+
+ {host.needs_update ? (
+ host.is_security_update ? (
+
+
+ Security Update
+
+ ) : (
+
+ Update Available
+
+ )
+ ) : (
+
+ Up to Date
+
+ )}
+ |
+
+ {host.last_updated
+ ? formatRelativeTime(host.last_updated)
+ : "Never"}
+ |
+
+ ))}
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ Rows per page:
+
+
+
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
);
diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx
index 75ca544..6cc3801 100644
--- a/frontend/src/pages/Packages.jsx
+++ b/frontend/src/pages/Packages.jsx
@@ -331,10 +331,14 @@ const Packages = () => {
switch (column.id) {
case "name":
return (
-
-
-
-
+
-
+
);
case "packageHosts": {
// Show total number of hosts where this package is installed
From 757feab9cd32b1bb58d2a314565080efa809b622 Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:10:19 +0100
Subject: [PATCH 5/7] fix(packages): add needsUpdate and isSecurityUpdate
fields to package hosts
---
backend/src/routes/packageRoutes.js | 1 +
frontend/src/pages/PackageDetail.jsx | 8 ++++----
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js
index 4188b06..ee2186f 100644
--- a/backend/src/routes/packageRoutes.js
+++ b/backend/src/routes/packageRoutes.js
@@ -126,6 +126,7 @@ router.get("/", async (req, res) => {
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
+ needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
})),
stats: {
diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx
index 8eb1abe..06db020 100644
--- a/frontend/src/pages/PackageDetail.jsx
+++ b/frontend/src/pages/PackageDetail.jsx
@@ -400,8 +400,8 @@ const PackageDetail = () => {
{host.current_version || "Unknown"}
- {host.needs_update ? (
- host.is_security_update ? (
+ {host.needsUpdate ? (
+ host.isSecurityUpdate ? (
Security Update
@@ -418,8 +418,8 @@ const PackageDetail = () => {
)}
|
- {host.last_updated
- ? formatRelativeTime(host.last_updated)
+ {host.lastUpdate
+ ? formatRelativeTime(host.lastUpdate)
: "Never"}
|
From f085596b87f1b0ffa750637497ff156cf947fe1d Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Thu, 2 Oct 2025 23:22:42 +0100
Subject: [PATCH 6/7] fix(packages): update host property names
---
frontend/src/pages/PackageDetail.jsx | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx
index 06db020..6df6d17 100644
--- a/frontend/src/pages/PackageDetail.jsx
+++ b/frontend/src/pages/PackageDetail.jsx
@@ -66,9 +66,7 @@ const PackageDetail = () => {
if (searchTerm) {
filtered = hosts.filter(
(host) =>
- host.friendly_name
- ?.toLowerCase()
- .includes(searchTerm.toLowerCase()) ||
+ host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
@@ -82,7 +80,7 @@ const PackageDetail = () => {
(searchTerm
? hosts.filter(
(host) =>
- host.friendly_name
+ host.friendlyName
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
@@ -377,18 +375,18 @@ const PackageDetail = () => {
{filteredAndPaginatedHosts.map((host) => (
handleHostClick(host.id)}
+ onClick={() => handleHostClick(host.hostId)}
>
- {host.friendly_name || host.hostname}
+ {host.friendlyName || host.hostname}
- {host.friendly_name && host.hostname && (
+ {host.friendlyName && host.hostname && (
{host.hostname}
@@ -397,7 +395,7 @@ const PackageDetail = () => {
|
- {host.current_version || "Unknown"}
+ {host.currentVersion || "Unknown"}
|
{host.needsUpdate ? (
From 482a9e27c9b51af0d8e3090b9146627c8004a903 Mon Sep 17 00:00:00 2001
From: tigattack <10629864+tigattack@users.noreply.github.com>
Date: Thu, 2 Oct 2025 23:26:14 +0100
Subject: [PATCH 7/7] fix(packages): fix security update badge
---
frontend/src/pages/Packages.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx
index 6cc3801..ffac6bb 100644
--- a/frontend/src/pages/Packages.jsx
+++ b/frontend/src/pages/Packages.jsx
@@ -396,7 +396,7 @@ const Packages = () => {
}
return pkg.isSecurityUpdate ? (
-
+
Security Update Available
|