mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
Refactored code to remove duplicate backend api endpoints for counting Improved connection persistence issues Improved database connection pooling issues Fixed redis connection efficiency Changed version to 1.3.0 Fixed GO binary detection based on package manager rather than OS
929 lines
31 KiB
JavaScript
929 lines
31 KiB
JavaScript
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
ArrowUpDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Columns,
|
|
Eye as EyeIcon,
|
|
EyeOff as EyeOffIcon,
|
|
GripVertical,
|
|
Package,
|
|
RefreshCw,
|
|
Search,
|
|
Server,
|
|
Shield,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import { dashboardAPI, packagesAPI } from "../utils/api";
|
|
|
|
const Packages = () => {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [categoryFilter, setCategoryFilter] = 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();
|
|
|
|
// Handle host filter from URL parameter
|
|
useEffect(() => {
|
|
const hostParam = searchParams.get("host");
|
|
if (hostParam) {
|
|
setHostFilter(hostParam);
|
|
}
|
|
}, [searchParams]);
|
|
|
|
// Column configuration
|
|
const [columnConfig, setColumnConfig] = useState(() => {
|
|
const defaultConfig = [
|
|
{ id: "name", label: "Package", visible: true, order: 0 },
|
|
{ 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 },
|
|
];
|
|
|
|
const saved = localStorage.getItem("packages-column-config");
|
|
if (saved) {
|
|
const savedConfig = JSON.parse(saved);
|
|
// Merge with defaults to handle new columns
|
|
return defaultConfig.map((defaultCol) => {
|
|
const savedCol = savedConfig.find((col) => col.id === defaultCol.id);
|
|
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol;
|
|
});
|
|
}
|
|
return defaultConfig;
|
|
});
|
|
|
|
// Update column configuration
|
|
const updateColumnConfig = (newConfig) => {
|
|
setColumnConfig(newConfig);
|
|
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
|
|
};
|
|
|
|
// 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();
|
|
params.set("selected", hostIds.join(","));
|
|
params.set("filter", "selected");
|
|
|
|
// Navigate to hosts page with selected hosts
|
|
navigate(`/hosts?${params.toString()}`);
|
|
};
|
|
|
|
// Handle URL filter parameters
|
|
useEffect(() => {
|
|
const filter = searchParams.get("filter");
|
|
if (filter === "outdated") {
|
|
// 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");
|
|
setUpdateStatusFilter("needs-updates");
|
|
} else if (filter === "security") {
|
|
// For security updates, filter to show only security updates
|
|
setUpdateStatusFilter("security-updates");
|
|
setCategoryFilter("all");
|
|
} else if (filter === "regular") {
|
|
// For regular (non-security) updates
|
|
setUpdateStatusFilter("regular-updates");
|
|
setCategoryFilter("all");
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const {
|
|
data: packagesResponse,
|
|
isLoading,
|
|
error,
|
|
refetch,
|
|
isFetching,
|
|
} = useQuery({
|
|
queryKey: ["packages", hostFilter, updateStatusFilter],
|
|
queryFn: () => {
|
|
const params = { limit: 10000 }; // High limit to effectively get all packages
|
|
if (hostFilter && hostFilter !== "all") {
|
|
params.host = hostFilter;
|
|
}
|
|
// Pass update status filter to backend to pre-filter packages
|
|
if (updateStatusFilter === "needs-updates") {
|
|
params.needsUpdate = "true";
|
|
} else if (updateStatusFilter === "security-updates") {
|
|
params.isSecurityUpdate = "true";
|
|
}
|
|
return packagesAPI.getAll(params).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 dashboard stats for card counts (consistent with homepage)
|
|
const { data: dashboardStats } = useQuery({
|
|
queryKey: ["dashboardStats"],
|
|
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
|
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
|
});
|
|
|
|
// Fetch hosts data to get total packages count
|
|
const { data: hosts } = useQuery({
|
|
queryKey: ["hosts"],
|
|
queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
|
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
|
});
|
|
|
|
// Filter and sort packages
|
|
const filteredAndSortedPackages = useMemo(() => {
|
|
if (!packages) return [];
|
|
|
|
// Filter packages
|
|
const filtered = packages.filter((pkg) => {
|
|
const matchesSearch =
|
|
pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
pkg.description?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
const matchesCategory =
|
|
categoryFilter === "all" || pkg.category === categoryFilter;
|
|
|
|
const matchesUpdateStatus =
|
|
updateStatusFilter === "all-packages" ||
|
|
(updateStatusFilter === "needs-updates" &&
|
|
(pkg.stats?.updatesNeeded || 0) > 0) ||
|
|
(updateStatusFilter === "security-updates" &&
|
|
(pkg.stats?.securityUpdates || 0) > 0) ||
|
|
(updateStatusFilter === "regular-updates" &&
|
|
(pkg.stats?.updatesNeeded || 0) > 0 &&
|
|
(pkg.stats?.securityUpdates || 0) === 0);
|
|
|
|
const packageHosts = pkg.packageHosts || [];
|
|
const matchesHost =
|
|
hostFilter === "all" ||
|
|
packageHosts.some((host) => host.hostId === hostFilter);
|
|
|
|
return (
|
|
matchesSearch && matchesCategory && matchesUpdateStatus && matchesHost
|
|
);
|
|
});
|
|
|
|
// Sorting
|
|
filtered.sort((a, b) => {
|
|
let aValue, bValue;
|
|
|
|
switch (sortField) {
|
|
case "name":
|
|
aValue = a.name?.toLowerCase() || "";
|
|
bValue = b.name?.toLowerCase() || "";
|
|
break;
|
|
case "latestVersion":
|
|
aValue = a.latestVersion?.toLowerCase() || "";
|
|
bValue = b.latestVersion?.toLowerCase() || "";
|
|
break;
|
|
case "packageHosts":
|
|
aValue = a.packageHostsCount || a.packageHosts?.length || 0;
|
|
bValue = b.packageHostsCount || b.packageHosts?.length || 0;
|
|
break;
|
|
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() || "";
|
|
}
|
|
|
|
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
|
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
return filtered;
|
|
}, [
|
|
packages,
|
|
searchTerm,
|
|
categoryFilter,
|
|
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)
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
// Sorting functions
|
|
const handleSort = (field) => {
|
|
if (sortField === field) {
|
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection("asc");
|
|
}
|
|
};
|
|
|
|
const getSortIcon = (field) => {
|
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
|
|
return sortDirection === "asc" ? (
|
|
<ArrowUp className="h-4 w-4" />
|
|
) : (
|
|
<ArrowDown className="h-4 w-4" />
|
|
);
|
|
};
|
|
|
|
// Column management functions
|
|
const toggleColumnVisibility = (columnId) => {
|
|
const newConfig = columnConfig.map((col) =>
|
|
col.id === columnId ? { ...col, visible: !col.visible } : col,
|
|
);
|
|
updateColumnConfig(newConfig);
|
|
};
|
|
|
|
const reorderColumns = (fromIndex, toIndex) => {
|
|
const newConfig = [...columnConfig];
|
|
const [movedColumn] = newConfig.splice(fromIndex, 1);
|
|
newConfig.splice(toIndex, 0, movedColumn);
|
|
|
|
// Update order values
|
|
const updatedConfig = newConfig.map((col, index) => ({
|
|
...col,
|
|
order: index,
|
|
}));
|
|
updateColumnConfig(updatedConfig);
|
|
};
|
|
|
|
const resetColumns = () => {
|
|
const defaultConfig = [
|
|
{ id: "name", label: "Package", visible: true, order: 0 },
|
|
{ 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);
|
|
};
|
|
|
|
// Helper function to render table cell content
|
|
const renderCellContent = (column, pkg) => {
|
|
switch (column.id) {
|
|
case "name":
|
|
return (
|
|
<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 && (
|
|
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
|
|
{pkg.description}
|
|
</div>
|
|
)}
|
|
{pkg.category && (
|
|
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
|
Category: {pkg.category}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
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={() => handlePackageHostsClick(pkg)}
|
|
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
|
|
title={titleText}
|
|
>
|
|
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
|
{displayText}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
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">
|
|
<Shield className="h-3 w-3" />
|
|
Security Update Available
|
|
</span>
|
|
) : (
|
|
<span className="badge-warning">Update Available</span>
|
|
);
|
|
}
|
|
case "latestVersion":
|
|
return (
|
|
<div
|
|
className="text-sm text-secondary-900 dark:text-white max-w-xs truncate"
|
|
title={pkg.latestVersion || "Unknown"}
|
|
>
|
|
{pkg.latestVersion || "Unknown"}
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Get unique categories
|
|
const categories =
|
|
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
|
|
|
|
// Calculate unique package hosts
|
|
const uniquePackageHosts = new Set();
|
|
packages?.forEach((pkg) => {
|
|
// 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 uniquePackageHostsCount = uniquePackageHosts.size;
|
|
|
|
// Calculate total packages installed
|
|
// Show unique package count (same as table) for consistency
|
|
const totalPackagesCount = packages?.length || 0;
|
|
|
|
// Calculate total installations across all hosts
|
|
const totalInstallationsCount =
|
|
packages?.reduce((sum, pkg) => sum + (pkg.stats?.totalInstalls || 0), 0) ||
|
|
0;
|
|
|
|
// Use dashboard stats for outdated packages count (consistent with homepage)
|
|
const outdatedPackagesCount =
|
|
dashboardStats?.cards?.totalOutdatedPackages || 0;
|
|
|
|
// Use dashboard stats for security updates count (consistent with homepage)
|
|
const securityUpdatesCount = dashboardStats?.cards?.securityUpdates || 0;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
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 packages
|
|
</h3>
|
|
<p className="text-sm text-danger-700 mt-1">
|
|
{error.message || "Failed to load packages"}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
className="mt-2 btn-danger text-xs"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
|
Packages
|
|
</h1>
|
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
|
Manage package updates and security patches
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
className="btn-outline flex items-center gap-2"
|
|
title="Refresh packages data"
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
|
/>
|
|
{isFetching ? "Refreshing..." : "Refresh"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4 mb-6 flex-shrink-0">
|
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
|
<div className="flex items-center">
|
|
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
|
<div>
|
|
<p className="text-sm text-secondary-500 dark:text-white">
|
|
Total Packages
|
|
</p>
|
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
{totalPackagesCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
|
<div className="flex items-center">
|
|
<Package className="h-5 w-5 text-blue-600 mr-2" />
|
|
<div>
|
|
<p className="text-sm text-secondary-500 dark:text-white">
|
|
Total Installations
|
|
</p>
|
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
{totalInstallationsCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
|
<div className="flex items-center">
|
|
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
|
<div>
|
|
<p className="text-sm text-secondary-500 dark:text-white">
|
|
Total Outdated Packages
|
|
</p>
|
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
{outdatedPackagesCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
|
<div className="flex items-center">
|
|
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
|
<div>
|
|
<p className="text-sm text-secondary-500 dark:text-white">
|
|
Hosts Pending Updates
|
|
</p>
|
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
{uniquePackageHostsCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
|
<div className="flex items-center">
|
|
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
|
<div>
|
|
<p className="text-sm text-secondary-500 dark:text-white">
|
|
Security Updates Across All Hosts
|
|
</p>
|
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
{securityUpdatesCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Packages List */}
|
|
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
|
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
|
<div className="flex items-center justify-end mb-4">
|
|
{/* Empty selection controls area to match hosts page spacing */}
|
|
</div>
|
|
|
|
{/* Table Controls */}
|
|
<div className="mb-4 space-y-4">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
{/* Search */}
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search packages..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
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>
|
|
|
|
{/* Category Filter */}
|
|
<div className="sm:w-48">
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(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 Categories</option>
|
|
{categories.map((category) => (
|
|
<option key={category} value={category}>
|
|
{category}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Update Status Filter */}
|
|
<div className="sm:w-48">
|
|
<select
|
|
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-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>
|
|
|
|
{/* Host Filter */}
|
|
<div className="sm:w-48">
|
|
<select
|
|
value={hostFilter}
|
|
onChange={(e) => setHostFilter(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 Hosts</option>
|
|
{hosts?.map((host) => (
|
|
<option key={host.id} value={host.id}>
|
|
{host.friendly_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Columns Button */}
|
|
<div className="flex items-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowColumnSettings(true)}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
|
|
>
|
|
<Columns className="h-4 w-4" />
|
|
Columns
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
{filteredAndSortedPackages.length === 0 ? (
|
|
<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">
|
|
{packages?.length === 0
|
|
? "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">
|
|
Packages will appear here once hosts start reporting their
|
|
installed packages
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="h-full overflow-auto">
|
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
|
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
|
<tr>
|
|
{visibleColumns.map((column) => (
|
|
<th
|
|
key={column.id}
|
|
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSort(column.id)}
|
|
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
|
>
|
|
{column.label}
|
|
{getSortIcon(column.id)}
|
|
</button>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
|
{paginatedPackages.map((pkg) => (
|
|
<tr
|
|
key={pkg.id}
|
|
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
|
>
|
|
{visibleColumns.map((column) => (
|
|
<td
|
|
key={column.id}
|
|
className="px-4 py-2 whitespace-nowrap text-center"
|
|
>
|
|
{renderCellContent(column, pkg)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
|
|
{/* Column Settings Modal */}
|
|
{showColumnSettings && (
|
|
<ColumnSettingsModal
|
|
columnConfig={columnConfig}
|
|
onClose={() => setShowColumnSettings(false)}
|
|
onToggleVisibility={toggleColumnVisibility}
|
|
onReorder={reorderColumns}
|
|
onReset={resetColumns}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Column Settings Modal Component
|
|
const ColumnSettingsModal = ({
|
|
columnConfig,
|
|
onClose,
|
|
onToggleVisibility,
|
|
onReorder,
|
|
onReset,
|
|
}) => {
|
|
const [draggedIndex, setDraggedIndex] = useState(null);
|
|
|
|
const handleDragStart = (e, index) => {
|
|
setDraggedIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
const handleDragOver = (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
};
|
|
|
|
const handleDrop = (e, dropIndex) => {
|
|
e.preventDefault();
|
|
if (draggedIndex !== null && draggedIndex !== dropIndex) {
|
|
onReorder(draggedIndex, dropIndex);
|
|
}
|
|
setDraggedIndex(null);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
|
Customize Columns
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{columnConfig.map((column, index) => (
|
|
<button
|
|
type="button"
|
|
key={column.id}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, index)}
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, index)}
|
|
className={`flex items-center justify-between p-3 border rounded-lg cursor-move w-full text-left ${
|
|
draggedIndex === index
|
|
? "opacity-50"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
} border-secondary-200 dark:border-secondary-600`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
|
{column.label}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggleVisibility(column.id)}
|
|
className={`p-1 rounded ${
|
|
column.visible
|
|
? "text-primary-600 hover:text-primary-700"
|
|
: "text-secondary-400 hover:text-secondary-600"
|
|
}`}
|
|
>
|
|
{column.visible ? (
|
|
<EyeIcon className="h-4 w-4" />
|
|
) : (
|
|
<EyeOffIcon className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<button
|
|
type="button"
|
|
onClick={onReset}
|
|
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
|
>
|
|
Reset to Default
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Packages;
|