mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
911 lines
30 KiB
JavaScript
911 lines
30 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 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
|
|
// When filtering by host, count each package once (since it can only be installed once per host)
|
|
// When not filtering, sum up all installations across all hosts
|
|
const totalPackagesCount =
|
|
hostFilter && hostFilter !== "all"
|
|
? packages?.length || 0
|
|
: packages?.reduce(
|
|
(sum, pkg) => sum + (pkg.stats?.totalInstalls || 0),
|
|
0,
|
|
) || 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.stats?.securityUpdates || 0) > 0).length ||
|
|
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-4 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 Installed
|
|
</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-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;
|