Compare commits

..

6 Commits

Author SHA1 Message Date
Muhammad Ibrahim
a853d36a6f Mobile fixes #1 2025-11-22 14:34:53 +00:00
9 Technology Group LTD
08f41bc9e0 Merge pull request #336 from PatchMon/release/1-3-5
put bash in for proxmox
2025-11-18 21:47:21 +00:00
Muhammad Ibrahim
6f7dbdb04b put bash in for proxmox 2025-11-18 21:46:37 +00:00
9 Technology Group LTD
909698ac11 Merge pull request #334 from PatchMon/release/1-3-5
Release/1 3 6
2025-11-18 20:36:12 +00:00
Muhammad Ibrahim
aaed443081 new binary 2025-11-18 20:13:12 +00:00
Muhammad Ibrahim
5b96b67db9 Fixed some prisma issue on kernel displaying
Fixed multi host selection button to not select all
updating to 1.3.6
2025-11-18 20:08:34 +00:00
17 changed files with 710 additions and 382 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.3.5",
"version": "1.3.6",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",

View File

@@ -103,6 +103,7 @@ model hosts {
gateway_ip String?
hostname String?
kernel_version String?
installed_kernel_version String?
load_average Json?
network_interfaces Json?
ram_installed Int?

View File

@@ -506,6 +506,10 @@ router.post(
.optional()
.isString()
.withMessage("Kernel version must be a string"),
body("installedKernelVersion")
.optional()
.isString()
.withMessage("Installed kernel version must be a string"),
body("selinuxStatus")
.optional()
.isIn(["enabled", "disabled", "permissive"])
@@ -587,6 +591,8 @@ router.post(
// System Information
if (req.body.kernelVersion)
updateData.kernel_version = req.body.kernelVersion;
if (req.body.installedKernelVersion)
updateData.installed_kernel_version = req.body.installedKernelVersion;
if (req.body.selinuxStatus)
updateData.selinux_status = req.body.selinuxStatus;
if (req.body.systemUptime)

View File

@@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1
# Application Metadata
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.3.5
VITE_APP_VERSION=1.3.6

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.3.5",
"version": "1.3.6",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
@@ -32,7 +32,7 @@
"devDependencies": {
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",

View File

@@ -215,7 +215,7 @@ const GlobalSearch = () => {
<input
ref={inputRef}
type="text"
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
className="block w-full rounded-lg border border-secondary-200 bg-white py-2.5 sm:py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400 min-h-[44px]"
placeholder="Search hosts, packages, repos, users..."
value={query}
onChange={handleInputChange}
@@ -228,7 +228,8 @@ const GlobalSearch = () => {
<button
type="button"
onClick={handleClear}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600 min-w-[44px] min-h-[44px] justify-center"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
@@ -237,9 +238,9 @@ const GlobalSearch = () => {
{/* Dropdown Results */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
<div className="absolute z-50 mt-2 w-full sm:w-[calc(100vw-2rem)] sm:max-w-md rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800 left-0 sm:left-auto right-0 sm:right-auto">
{isLoading ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
Searching...
</div>
) : hasResults ? (
@@ -247,7 +248,7 @@ const GlobalSearch = () => {
{/* Hosts */}
{results.hosts?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
Hosts
</div>
{results.hosts.map((host, _idx) => {
@@ -260,7 +261,7 @@ const GlobalSearch = () => {
type="button"
key={host.id}
onClick={() => handleResultClick(host)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
@@ -271,12 +272,14 @@ const GlobalSearch = () => {
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
<span className="text-xs text-secondary-400 dark:text-white/50">
</span>
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
{host.os_type}
</div>
</button>
@@ -288,7 +291,7 @@ const GlobalSearch = () => {
{/* Packages */}
{results.packages?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
Packages
</div>
{results.packages.map((pkg, _idx) => {
@@ -301,7 +304,7 @@ const GlobalSearch = () => {
type="button"
key={pkg.id}
onClick={() => handleResultClick(pkg)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
@@ -317,13 +320,13 @@ const GlobalSearch = () => {
<span className="text-xs text-secondary-400">
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
{display.secondary}
</span>
</>
)}
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
{pkg.host_count} hosts
</div>
</button>
@@ -335,7 +338,7 @@ const GlobalSearch = () => {
{/* Repositories */}
{results.repositories?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
Repositories
</div>
{results.repositories.map((repo, _idx) => {
@@ -348,7 +351,7 @@ const GlobalSearch = () => {
type="button"
key={repo.id}
onClick={() => handleResultClick(repo)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
@@ -359,12 +362,14 @@ const GlobalSearch = () => {
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
<span className="text-xs text-secondary-400 dark:text-white/50">
</span>
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
{repo.host_count} hosts
</div>
</button>
@@ -376,7 +381,7 @@ const GlobalSearch = () => {
{/* Users */}
{results.users?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
Users
</div>
{results.users.map((user, _idx) => {
@@ -389,7 +394,7 @@ const GlobalSearch = () => {
type="button"
key={user.id}
onClick={() => handleResultClick(user)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
@@ -400,12 +405,14 @@ const GlobalSearch = () => {
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
<span className="text-xs text-secondary-400 dark:text-white/50">
</span>
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
{user.role}
</div>
</button>
@@ -415,7 +422,7 @@ const GlobalSearch = () => {
)}
</div>
) : query.trim() ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
No results found for "{query}"
</div>
) : null}

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,6 @@ import {
Server,
Settings,
Shield,
TrendingUp,
Users,
WifiOff,
} from "lucide-react";
@@ -59,6 +58,7 @@ const Dashboard = () => {
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
const [systemStatsJobId, setSystemStatsJobId] = useState(null); // Track job ID for system statistics
const [isTriggeringJob, setIsTriggeringJob] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 640);
const navigate = useNavigate();
const { isDark } = useTheme();
const { user } = useAuth();
@@ -312,6 +312,15 @@ const Dashboard = () => {
};
}, []);
// Track window size for responsive chart options
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 640);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Helper function to check if a card should be displayed
const isCardEnabled = (cardId) => {
const card = cardPreferences.find((c) => c.cardId === cardId);
@@ -358,11 +367,11 @@ const Dashboard = () => {
const getGroupClassName = (cardType) => {
switch (cardType) {
case "stats":
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
return "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4";
case "charts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
case "widecharts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
case "fullwidth":
return "space-y-6";
default:
@@ -377,7 +386,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleNeedsRebootClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -405,7 +414,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleTotalHostsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -434,7 +443,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleHostsNeedingUpdatesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -463,7 +472,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleUpToDateClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -492,7 +501,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleOutdatedPackagesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -521,7 +530,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleSecurityUpdatesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -550,7 +559,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleHostGroupsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -579,7 +588,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleUsersClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -608,7 +617,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
onClick={handleRepositoriesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -741,7 +750,7 @@ const Dashboard = () => {
case "osDistribution":
return (
<div className="card p-6 w-full">
<div className="card p-4 sm:p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
@@ -755,7 +764,7 @@ const Dashboard = () => {
case "osDistributionDoughnut":
return (
<div className="card p-6 w-full">
<div className="card p-4 sm:p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
@@ -769,7 +778,7 @@ const Dashboard = () => {
case "osDistributionBar":
return (
<div className="card p-6 w-full">
<div className="card p-4 sm:p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
@@ -783,7 +792,7 @@ const Dashboard = () => {
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
className="card p-4 sm:p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleUpdateStatusClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -808,7 +817,7 @@ const Dashboard = () => {
case "packagePriority":
return (
<div className="card p-6 w-full">
<div className="card p-4 sm:p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Outdated Packages by Priority
</h3>
@@ -825,13 +834,13 @@ const Dashboard = () => {
case "packageTrends":
return (
<div className="card p-6 w-full">
<div className="flex items-center justify-between mb-4">
<div className="card p-4 sm:p-6 w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Package Trends Over Time
</h3>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
{/* Refresh Button */}
<button
type="button"
@@ -869,7 +878,7 @@ const Dashboard = () => {
}
}}
disabled={packageTrendsFetching || isTriggeringJob}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-h-[44px]"
title={
packageTrendsHost === "all"
? "Trigger system statistics collection"
@@ -890,7 +899,7 @@ const Dashboard = () => {
<select
value={packageTrendsPeriod}
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
>
<option value="1">Last 24 hours</option>
<option value="7">Last 7 days</option>
@@ -908,7 +917,7 @@ const Dashboard = () => {
// Clear job ID message when host selection changes
setSystemStatsJobId(null);
}}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
>
<option value="all">All Hosts</option>
{packageTrendsData?.hosts?.length > 0 ? (
@@ -928,7 +937,7 @@ const Dashboard = () => {
</div>
{/* Job ID Message */}
{systemStatsJobId && packageTrendsHost === "all" && (
<p className="text-xs text-secondary-600 dark:text-secondary-400 ml-1">
<p className="text-xs text-secondary-600 dark:text-white/70 ml-1">
Ran collection job #{systemStatsJobId}
</p>
)}
@@ -946,7 +955,7 @@ const Dashboard = () => {
options={packageTrendsChartOptions}
/>
) : (
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-white/70">
No data available
</div>
)}
@@ -984,7 +993,7 @@ const Dashboard = () => {
: 0;
return (
<div className="card p-6">
<div className="card p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
System Overview
@@ -995,10 +1004,10 @@ const Dashboard = () => {
<div className="text-2xl font-bold text-primary-600">
{updatePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
<div className="text-sm text-secondary-500 dark:text-white/70">
Need Updates
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
<div className="text-xs text-secondary-400 dark:text-white/60">
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
hosts
</div>
@@ -1007,10 +1016,10 @@ const Dashboard = () => {
<div className="text-2xl font-bold text-danger-600">
{stats.cards.securityUpdates}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
<div className="text-sm text-secondary-500 dark:text-white/70">
Security Issues
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
<div className="text-xs text-secondary-400 dark:text-white/60">
{securityPercentage}% of updates
</div>
</div>
@@ -1018,10 +1027,10 @@ const Dashboard = () => {
<div className="text-2xl font-bold text-success-600">
{onlinePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
<div className="text-sm text-secondary-500 dark:text-white/70">
Online
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
<div className="text-xs text-secondary-400 dark:text-white/60">
{onlineHosts}/{stats.cards.totalHosts} hosts
</div>
</div>
@@ -1029,10 +1038,10 @@ const Dashboard = () => {
<div className="text-2xl font-bold text-secondary-600">
{avgPackagesPerHost}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
<div className="text-sm text-secondary-500 dark:text-white/70">
Avg per Host
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
<div className="text-xs text-secondary-400 dark:text-white/60">
outdated packages
</div>
</div>
@@ -1043,7 +1052,7 @@ const Dashboard = () => {
case "recentUsers":
return (
<div className="card p-6">
<div className="card p-4 sm:p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Recent Users Logged in
</h3>
@@ -1057,7 +1066,7 @@ const Dashboard = () => {
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{u.username}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<div className="text-sm text-secondary-500 dark:text-white/70">
{u.last_login
? formatRelativeTime(u.last_login)
: "Never"}
@@ -1065,7 +1074,7 @@ const Dashboard = () => {
</div>
))}
{(!recentUsers || recentUsers.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
No users found
</div>
)}
@@ -1076,7 +1085,7 @@ const Dashboard = () => {
case "recentCollection":
return (
<div className="card p-6">
<div className="card p-4 sm:p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Recent Collection
</h3>
@@ -1094,7 +1103,7 @@ const Dashboard = () => {
>
{host.friendly_name || host.hostname}
</button>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<div className="text-sm text-secondary-500 dark:text-white/70">
{host.last_update
? formatRelativeTime(host.last_update)
: "Never"}
@@ -1102,7 +1111,7 @@ const Dashboard = () => {
</div>
))}
{(!recentCollection || recentCollection.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
No hosts found
</div>
)}
@@ -1154,13 +1163,13 @@ const Dashboard = () => {
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
position: isMobile ? "bottom" : "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
size: isMobile ? 10 : 12,
},
padding: 15,
padding: isMobile ? 10 : 15,
usePointStyle: true,
pointStyle: "circle",
},
@@ -1168,7 +1177,7 @@ const Dashboard = () => {
},
layout: {
padding: {
right: 20,
right: isMobile ? 10 : 20,
},
},
onClick: handleOSChartClick,
@@ -1179,13 +1188,13 @@ const Dashboard = () => {
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
position: isMobile ? "bottom" : "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
size: isMobile ? 10 : 12,
},
padding: 15,
padding: isMobile ? 10 : 15,
usePointStyle: true,
pointStyle: "circle",
},
@@ -1193,7 +1202,7 @@ const Dashboard = () => {
},
layout: {
padding: {
right: 20,
right: isMobile ? 10 : 20,
},
},
onClick: handleOSChartClick,
@@ -1584,10 +1593,10 @@ const Dashboard = () => {
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
<h1 className="text-xl sm:text-2xl font-semibold text-secondary-900 dark:text-white">
Welcome back, {user?.first_name || user?.username || "User"} 👋
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
Overview of your PatchMon infrastructure
</p>
</div>
@@ -1595,7 +1604,7 @@ const Dashboard = () => {
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="btn-outline flex items-center gap-2"
className="hidden md:flex btn-outline items-center gap-2"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
@@ -1604,7 +1613,7 @@ const Dashboard = () => {
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
className="btn-outline flex items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
title="Refresh dashboard data"
>
<RefreshCw

View File

@@ -1013,29 +1013,25 @@ const HostDetail = () => {
</div>
)}
{(host.kernel_version ||
host.installed_kernel_version) && (
<div className="flex flex-col gap-2">
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
Running Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.kernel_version}
</p>
</div>
)}
{host.installed_kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
Installed Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.installed_kernel_version}
</p>
</div>
)}
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Running Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.kernel_version}
</p>
</div>
)}
{host.installed_kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Installed Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.installed_kernel_version}
</p>
</div>
)}

View File

@@ -13,6 +13,7 @@ import {
Eye as EyeIcon,
EyeOff as EyeOffIcon,
Filter,
FolderPlus,
GripVertical,
Plus,
RefreshCw,
@@ -21,7 +22,6 @@ import {
Server,
Square,
Trash2,
Users,
Wifi,
X,
} from "lucide-react";
@@ -94,8 +94,8 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
if (!isOpen) return 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="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 sm:p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Add New Host
@@ -125,7 +125,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
onChange={(e) =>
setFormData({ ...formData, friendly_name: e.target.value })
}
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
className="block w-full px-3 py-3 sm:py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200 min-h-[44px]"
placeholder="server.example.com"
/>
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
@@ -197,18 +197,18 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
</div>
)}
<div className="flex justify-end space-x-3 pt-2">
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
>
{isSubmitting ? "Creating..." : "Create Host"}
</button>
@@ -649,11 +649,27 @@ const Hosts = () => {
);
};
const handleSelectAll = () => {
if (selectedHosts.length === hosts.length) {
setSelectedHosts([]);
const handleSelectAll = (hostsToSelect) => {
const hostIdsToSelect = hostsToSelect.map((host) => host.id);
const allSelected = hostIdsToSelect.every((id) =>
selectedHosts.includes(id),
);
if (allSelected) {
// Deselect all hosts in this group
setSelectedHosts((prev) =>
prev.filter((id) => !hostIdsToSelect.includes(id)),
);
} else {
setSelectedHosts(hosts.map((host) => host.id));
// Select all hosts in this group (merge with existing selections)
setSelectedHosts((prev) => {
const newSelection = [...prev];
hostIdsToSelect.forEach((id) => {
if (!newSelection.includes(id)) {
newSelection.push(id);
}
});
return newSelection;
});
}
};
@@ -1106,11 +1122,17 @@ const Hosts = () => {
}
>
<div
className={`w-2 h-2 rounded-full mr-1.5 ${
className={`w-2 h-2 rounded-full ${wsStatus.connected ? "mr-1.5" : "mr-1.5 md:mr-0"} ${
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
}`}
></div>
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
<span className="hidden md:inline">
{wsStatus.connected
? wsStatus.secure
? "WSS"
: "WS"
: "Offline"}
</span>
</span>
);
}
@@ -1285,14 +1307,14 @@ const Hosts = () => {
}
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
<div className="min-h-0 flex flex-col md:h-[calc(100vh-7rem)] md: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">
Hosts
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
Manage and monitor your connected hosts
</p>
</div>
@@ -1320,7 +1342,7 @@ const Hosts = () => {
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6">
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
@@ -1404,7 +1426,7 @@ const Hosts = () => {
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{connectedCount}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
Connected
</span>
</div>
@@ -1413,7 +1435,7 @@ const Hosts = () => {
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{offlineCount}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
Offline
</span>
</div>
@@ -1426,37 +1448,39 @@ const Hosts = () => {
</div>
{/* Hosts 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">
<div className="card flex-1 flex flex-col md:overflow-hidden min-h-0">
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col md:overflow-hidden min-h-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-end gap-3 mb-4">
{selectedHosts.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm text-secondary-600">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="text-sm text-secondary-600 dark:text-white/80 flex-shrink-0">
{selectedHosts.length} host
{selectedHosts.length !== 1 ? "s" : ""} selected
</span>
<button
type="button"
onClick={() => setShowBulkAssignModal(true)}
className="btn-outline flex items-center gap-2"
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
>
<Users className="h-4 w-4" />
Assign to Group
<FolderPlus className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Assign to Group</span>
<span className="sm:hidden">Assign</span>
</button>
<button
type="button"
onClick={() => setShowBulkDeleteModal(true)}
className="btn-danger flex items-center gap-2"
className="btn-danger flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
>
<Trash2 className="h-4 w-4" />
Delete
<Trash2 className="h-4 w-4 flex-shrink-0" />
<span>Delete</span>
</button>
<button
type="button"
onClick={() => setSelectedHosts([])}
className="text-sm text-secondary-500 hover:text-secondary-700"
className="text-xs sm:text-sm text-secondary-500 dark:text-white/70 hover:text-secondary-700 dark:hover:text-white/90 min-h-[44px] px-2"
>
Clear Selection
<span className="hidden sm:inline">Clear Selection</span>
<span className="sm:hidden">Clear</span>
</button>
</div>
)}
@@ -1478,28 +1502,28 @@ const Hosts = () => {
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`btn-outline flex items-center gap-2 ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
>
<Filter className="h-4 w-4" />
Filters
<Filter className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Filters</span>
</button>
<button
type="button"
onClick={() => setShowColumnSettings(true)}
className="btn-outline flex items-center gap-2"
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
>
<Columns className="h-4 w-4" />
Columns
<Columns className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Columns</span>
</button>
<div className="relative">
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-xs sm:text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[100px] sm:min-w-[120px] min-h-[44px]"
>
<option value="none">No Grouping</option>
<option value="group">By Group</option>
@@ -1511,18 +1535,18 @@ const Hosts = () => {
<button
type="button"
onClick={() => setHideStale(!hideStale)}
className={`btn-outline flex items-center gap-2 ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
>
<AlertTriangle className="h-4 w-4" />
Hide Stale
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Hide Stale</span>
</button>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg border dark:border-secondary-600">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-secondary-50 dark:bg-secondary-700 p-3 sm:p-4 rounded-lg border dark:border-secondary-600">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div>
<label
htmlFor={hostGroupFilterId}
@@ -1534,7 +1558,7 @@ const Hosts = () => {
id={hostGroupFilterId}
value={groupFilter}
onChange={(e) => setGroupFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
>
<option value="all">All Groups</option>
<option value="ungrouped">Ungrouped</option>
@@ -1556,7 +1580,7 @@ const Hosts = () => {
id={statusFilterId}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
>
<option value="all">All Status</option>
<option value="active">Active</option>
@@ -1576,7 +1600,7 @@ const Hosts = () => {
id={osFilterId}
value={osFilter}
onChange={(e) => setOsFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
>
<option value="all">All OS</option>
{uniqueOsTypes.map((osType) => (
@@ -1597,7 +1621,7 @@ const Hosts = () => {
setGroupBy("none");
setHideStale(false);
}}
className="btn-outline w-full"
className="btn-outline w-full min-h-[44px]"
>
Clear Filters
</button>
@@ -1607,7 +1631,7 @@ const Hosts = () => {
)}
</div>
<div className="flex-1 overflow-hidden">
<div className="flex-1 md:overflow-hidden">
{!hosts || hosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
@@ -1628,7 +1652,7 @@ const Hosts = () => {
</p>
</div>
) : (
<div className="h-full overflow-auto">
<div className="md:h-full overflow-auto">
<div className="space-y-6">
{Object.entries(groupedHosts).map(
([groupName, groupHosts]) => (
@@ -1642,24 +1666,246 @@ const Hosts = () => {
</div>
)}
{/* Table for this group */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
{/* Mobile Card Layout */}
<div className="md:hidden space-y-3">
{groupHosts.map((host) => {
const isInactive =
(host.effectiveStatus || host.status) ===
"inactive";
const isSelected = selectedHosts.includes(host.id);
const wsStatus = wsStatusMap[host.api_id];
const groupIds =
host.host_group_memberships?.map(
(membership) => membership.host_groups.id,
) || [];
const groups =
hostGroups?.filter((g) =>
groupIds.includes(g.id),
) || [];
return (
<div
key={host.id}
className={`card p-4 space-y-3 ${
isSelected
? "ring-2 ring-primary-500 bg-primary-50 dark:bg-primary-900/20"
: isInactive
? "bg-red-50 dark:bg-red-900/20"
: ""
}`}
>
{/* Header with select and main info */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{visibleColumns.some(
(col) => col.id === "select",
) && (
<button
type="button"
onClick={() =>
handleSelectHost(host.id)
}
className="flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center"
>
{isSelected ? (
<CheckSquare className="h-5 w-5 text-primary-600" />
) : (
<Square className="h-5 w-5 text-secondary-400" />
)}
</button>
)}
<div className="flex-1 min-w-0">
{visibleColumns.some(
(col) => col.id === "host",
) && (
<Link
to={`/hosts/${host.id}`}
className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 block truncate"
>
{host.friendly_name || "Unnamed Host"}
</Link>
)}
{visibleColumns.some(
(col) => col.id === "hostname",
) &&
host.hostname && (
<div className="text-sm text-secondary-500 dark:text-secondary-400 font-mono truncate">
{host.hostname}
</div>
)}
</div>
</div>
{visibleColumns.some(
(col) => col.id === "actions",
) && (
<Link
to={`/hosts/${host.id}`}
className="btn-primary text-sm px-3 py-2 min-h-[44px] flex items-center gap-1 flex-shrink-0"
>
View
<ExternalLink className="h-4 w-4" />
</Link>
)}
</div>
{/* Status and connection info */}
<div className="flex flex-wrap items-center gap-2">
{visibleColumns.some(
(col) => col.id === "status",
) && (
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300">
{(host.effectiveStatus || host.status)
.charAt(0)
.toUpperCase() +
(
host.effectiveStatus || host.status
).slice(1)}
</span>
)}
{visibleColumns.some(
(col) => col.id === "ws_status",
) &&
wsStatus && (
<span
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
>
<div
className={`w-2 h-2 rounded-full ${wsStatus.connected ? "mr-1.5" : "mr-1.5 md:mr-0"} ${
wsStatus.connected
? "bg-green-500 animate-pulse"
: "bg-red-500"
}`}
></div>
<span className="hidden md:inline">
{wsStatus.connected
? wsStatus.secure
? "WSS"
: "WS"
: "Offline"}
</span>
</span>
)}
{visibleColumns.some(
(col) => col.id === "needs_reboot",
) &&
(host.needs_reboot ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<RotateCcw className="h-3 w-3" />
Reboot Required
</span>
) : null)}
</div>
{/* OS and Group info */}
<div className="flex flex-wrap items-center gap-3 text-sm">
{visibleColumns.some(
(col) => col.id === "os",
) && (
<div className="flex items-center gap-2">
<OSIcon
osType={host.os_type}
className="h-4 w-4"
/>
<span className="text-secondary-700 dark:text-secondary-300">
{getOSDisplayName(host.os_type)}
</span>
</div>
)}
{visibleColumns.some(
(col) => col.id === "group",
) &&
groups.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<span className="text-secondary-500 dark:text-secondary-400">
Groups:
</span>
{groups.map((g, idx) => (
<span
key={g.id}
className="text-secondary-700 dark:text-secondary-300"
>
{g.name}
{idx < groups.length - 1 ? "," : ""}
</span>
))}
</div>
)}
</div>
{/* Updates info */}
<div className="flex items-center gap-4 pt-2 border-t border-secondary-200 dark:border-secondary-600">
{visibleColumns.some(
(col) => col.id === "updates",
) && (
<button
type="button"
onClick={() =>
navigate(
`/packages?host=${host.id}&filter=outdated`,
)
}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium min-h-[44px] flex items-center"
>
{host.updatesCount || 0} Updates
</button>
)}
{visibleColumns.some(
(col) => col.id === "security_updates",
) && (
<button
type="button"
onClick={() =>
navigate(
`/packages?host=${host.id}&filter=security-updates`,
)
}
className="text-sm text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 font-medium min-h-[44px] flex items-center"
>
{host.securityUpdatesCount || 0} Security
</button>
)}
{visibleColumns.some(
(col) => col.id === "last_update",
) && (
<div className="text-xs text-secondary-500 dark:text-secondary-400 ml-auto">
Updated{" "}
{formatRelativeTime(host.last_update)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Desktop Table Layout */}
<div className="hidden md:block overflow-x-auto">
<table
className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"
style={{ minWidth: "max-content" }}
>
<thead className="bg-secondary-50 dark:bg-secondary-700">
<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"
className="px-3 sm:px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider whitespace-nowrap"
>
{column.id === "select" ? (
<button
type="button"
onClick={handleSelectAll}
onClick={() =>
handleSelectAll(groupHosts)
}
className="flex items-center gap-2 hover:text-secondary-700"
>
{selectedHosts.length ===
groupHosts.length ? (
{groupHosts.every((host) =>
selectedHosts.includes(host.id),
) ? (
<CheckSquare className="h-4 w-4" />
) : (
<Square className="h-4 w-4" />
@@ -1823,7 +2069,7 @@ const Hosts = () => {
{visibleColumns.map((column) => (
<td
key={column.id}
className="px-4 py-2 whitespace-nowrap text-center"
className="px-3 sm:px-4 py-2 whitespace-nowrap text-center"
>
{renderCellContent(column, host)}
</td>
@@ -2165,7 +2411,6 @@ const ColumnSettingsModal = ({
key={column.id}
type="button"
draggable
tabIndex={0}
aria-label={`Drag to reorder ${column.label} column`}
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
@@ -2176,7 +2421,7 @@ const ColumnSettingsModal = ({
// Focus handling for keyboard users
}
}}
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full transition-colors ${
draggedIndex === index
? "opacity-50"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
@@ -2190,12 +2435,20 @@ const ColumnSettingsModal = ({
</div>
<button
type="button"
onClick={() => onToggleVisibility(column.id)}
className={`p-1 rounded transition-colors flex-shrink-0 ${
onClick={(e) => {
e.stopPropagation();
onToggleVisibility(column.id);
}}
className={`p-1 rounded transition-colors flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center ${
column.visible
? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
: "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
}`}
aria-label={
column.visible
? `Hide ${column.label} column`
: `Show ${column.label} column`
}
>
{column.visible ? (
<EyeIcon className="h-4 w-4" />

View File

@@ -1586,7 +1586,7 @@ const Integrations = () => {
<div className="flex items-center gap-2">
<input
type="text"
value={`curl ${curl_flags} "${getEnrollmentUrl()}" | sh`}
value={`curl ${curl_flags} "${getEnrollmentUrl()}" | ${selected_script_type === "proxmox-lxc" ? "bash" : "sh"}`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
@@ -1594,7 +1594,7 @@ const Integrations = () => {
type="button"
onClick={() =>
copy_to_clipboard(
`curl ${curl_flags} "${getEnrollmentUrl()}" | sh`,
`curl ${curl_flags} "${getEnrollmentUrl()}" | ${selected_script_type === "proxmox-lxc" ? "bash" : "sh"}`,
"enrollment-command",
)
}

216
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.3.5",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.3.5",
"version": "1.3.4",
"license": "AGPL-3.0",
"workspaces": [
"backend",
@@ -23,7 +23,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.3.5",
"version": "1.3.4",
"license": "AGPL-3.0",
"dependencies": {
"@bull-board/api": "^6.13.1",
@@ -59,7 +59,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.3.5",
"version": "1.3.4",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -83,7 +83,7 @@
"devDependencies": {
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
@@ -131,22 +131,19 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"version": "7.28.4",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.5",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/traverse": "^7.28.4",
"@babel/types": "^7.28.4",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -163,14 +160,12 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"version": "7.28.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -247,9 +242,7 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.27.1",
"dev": true,
"license": "MIT",
"engines": {
@@ -277,13 +270,11 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"version": "7.28.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -342,18 +333,16 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"version": "7.28.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.5",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.5",
"@babel/types": "^7.28.4",
"debug": "^4.3.1"
},
"engines": {
@@ -361,14 +350,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"version": "7.28.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -560,7 +547,6 @@
"node_modules/@bull-board/ui": {
"version": "6.13.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@bull-board/api": "6.13.1"
}
@@ -594,7 +580,6 @@
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -910,9 +895,7 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
"integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==",
"version": "1.0.0-beta.27",
"dev": true,
"license": "MIT"
},
@@ -1037,7 +1020,6 @@
"version": "18.3.24",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1056,21 +1038,19 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
"integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==",
"version": "4.7.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.47",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.18.0"
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
@@ -1287,7 +1267,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -1477,7 +1456,6 @@
"node_modules/chart.js": {
"version": "4.5.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2052,7 +2030,6 @@
"node_modules/express": {
"version": "4.21.2",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -2818,76 +2795,6 @@
"lefthook-windows-x64": "1.13.5"
}
},
"node_modules/lefthook-darwin-arm64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.13.5.tgz",
"integrity": "sha512-BYt5CnAOXasVCS6i+A4ljUo9xru/B5uMFD6EWHhs3R26jGF7mBSDxM3ErzXTUaJRTP0kQI/XBmgqBryBqoqZOQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/lefthook-darwin-x64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-1.13.5.tgz",
"integrity": "sha512-ZDtLBzvI5e26C/RZ4irOHpELTd22x9lDTgF2+eCYcnrBWOkB7800V8tuAvBybsLGvg6JwKjFxn+NTRNZnCC2hw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/lefthook-freebsd-arm64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-1.13.5.tgz",
"integrity": "sha512-uQ/kQZSSedw74aGCpsfOPN4yVt3klg8grOP6gHQOCRUMv5oK/Lj3pe1PylpTuuhxWORWRzkauPMot26J0OZZdA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/lefthook-freebsd-x64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-1.13.5.tgz",
"integrity": "sha512-6czek8XagVrI7ExURawkfrfX40Qjc/wktc8bLq/iXfRlmdvKDMrx2FrA82mDfEVCAEz+tTvkteK1TfR3icYF3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/lefthook-linux-arm64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-1.13.5.tgz",
"integrity": "sha512-MjWtiuW1br+rpTtgG1KGV53mSGtL5MWQwgafYzrFleJ89fKb86F4TD/4mVNzk5thmZ+HVPZw9bRZGUHFBnNJWg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/lefthook-linux-x64": {
"version": "1.13.5",
"cpu": [
@@ -2900,62 +2807,6 @@
"linux"
]
},
"node_modules/lefthook-openbsd-arm64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-1.13.5.tgz",
"integrity": "sha512-lYXrWf0/hBrwtG8ceaHq886bcqRKh3Lfv+jZJs+ykMLB6L/kaqk8tA4V2NHWydQ5h56o45ugs/580nMz36ZdRg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/lefthook-openbsd-x64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-1.13.5.tgz",
"integrity": "sha512-Ba1JrsRbfan4WKd8Q7gUhTxCUuppXzirDObd3JxpLRSLxA47yxhjMv7KByDunRDTvzTgsXoykZI6mPupkc1JiQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/lefthook-windows-arm64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-1.13.5.tgz",
"integrity": "sha512-Y/CpmEIb0hlFe+kTT/efWgX6+/gUTp5NItTF+gmUrY1/G/bTLIxdIRS7WpodVM0MEN24sOrQVTSi9DN9FvGoGg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/lefthook-windows-x64": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-1.13.5.tgz",
"integrity": "sha512-WJBqGNBlFJnunRwy12QyaDHdGULtostPqpYSZSS4boFJDY0lP5qtz9lAGmJ49aA5GQ19jrnDjGLwVPFiwIqksQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
@@ -3568,7 +3419,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3698,7 +3548,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
@@ -3888,7 +3737,6 @@
"node_modules/react": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3907,7 +3755,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3924,9 +3771,7 @@
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"version": "0.17.0",
"dev": true,
"license": "MIT",
"engines": {
@@ -4627,7 +4472,6 @@
"version": "4.0.3",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4780,7 +4624,6 @@
"version": "7.1.7",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4870,7 +4713,6 @@
"version": "4.0.3",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.3.5",
"version": "1.3.6",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,