mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-16 03:41:43 +00:00
I should really commit more often instead of sending over one massive commit
Blame my ADHD brain Sorry - Now we have the server working properly in automation using BullMQ and Redis - It also presents an API endpoint that is used to accept connections for websockets by agents (WS or WSS) - Updated the docker-compose.yml and its documentation
This commit is contained in:
283
frontend/src/components/InlineMultiGroupEdit.jsx
Normal file
283
frontend/src/components/InlineMultiGroupEdit.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Check, ChevronDown, Edit2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const InlineMultiGroupEdit = ({
|
||||
value = [], // Array of group IDs
|
||||
onSave,
|
||||
onCancel,
|
||||
options = [],
|
||||
className = "",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState(value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const dropdownRef = useRef(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && dropdownRef.current) {
|
||||
dropdownRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValues(value);
|
||||
// Force re-render when value changes
|
||||
if (!isEditing) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// Calculate dropdown position
|
||||
const calculateDropdownPosition = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
calculateDropdownPosition();
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("resize", calculateDropdownPosition);
|
||||
window.addEventListener("scroll", calculateDropdownPosition);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("resize", calculateDropdownPosition);
|
||||
window.removeEventListener("scroll", calculateDropdownPosition);
|
||||
};
|
||||
}
|
||||
}, [isOpen, calculateDropdownPosition]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setSelectedValues(value);
|
||||
setError("");
|
||||
// Automatically open dropdown when editing starts
|
||||
setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedValues(value);
|
||||
setError("");
|
||||
setIsOpen(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
// Check if values actually changed
|
||||
const sortedCurrent = [...value].sort();
|
||||
const sortedSelected = [...selectedValues].sort();
|
||||
if (JSON.stringify(sortedCurrent) === JSON.stringify(sortedSelected)) {
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await onSave(selectedValues);
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to save");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId) => {
|
||||
setSelectedValues((prev) => {
|
||||
if (prev.includes(groupId)) {
|
||||
return prev.filter((id) => id !== groupId);
|
||||
} else {
|
||||
return [...prev, groupId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _displayValue = useMemo(() => {
|
||||
if (!value || value.length === 0) {
|
||||
return "Ungrouped";
|
||||
}
|
||||
if (value.length === 1) {
|
||||
const option = options.find((opt) => opt.id === value[0]);
|
||||
return option ? option.name : "Unknown Group";
|
||||
}
|
||||
return `${value.length} groups`;
|
||||
}, [value, options]);
|
||||
|
||||
const displayGroups = useMemo(() => {
|
||||
if (!value || value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((groupId) => options.find((opt) => opt.id === groupId))
|
||||
.filter(Boolean);
|
||||
}, [value, options]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className={`w-full px-3 py-1 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:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
|
||||
error ? "border-red-500" : ""
|
||||
} ${isLoading ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedValues.length === 0
|
||||
? "Ungrouped"
|
||||
: selectedValues.length === 1
|
||||
? options.find((opt) => opt.id === selectedValues[0])
|
||||
?.name || "Unknown Group"
|
||||
: `${selectedValues.length} groups selected`}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.id)}
|
||||
onChange={() => toggleGroup(option.id)}
|
||||
className="mr-2 h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: option.color }}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
No groups available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 group ${className}`}>
|
||||
{displayGroups.length === 0 ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
|
||||
Ungrouped
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{displayGroups.map((group) => (
|
||||
<span
|
||||
key={group.id}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Edit groups"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineMultiGroupEdit;
|
||||
@@ -26,7 +26,7 @@ const AgentManagementTab = () => {
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const getCurlFlags = () => {
|
||||
const _getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
@@ -177,29 +177,40 @@ const AgentManagementTab = () => {
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-2">
|
||||
<p className="mb-3">
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
curl {getCurlFlags()} {window.location.origin}
|
||||
/api/v1/hosts/remove | sudo bash
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
|
||||
<code>--force</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
|
||||
navigator.clipboard.writeText(command);
|
||||
// You could add a toast notification here
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This will remove all PatchMon files, configuration, and
|
||||
crontab entries
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -446,6 +446,53 @@ const AgentUpdatesTab = () => {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Uninstall Instructions */}
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<Shield className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-3">To completely remove PatchMon from a host:</p>
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
|
||||
<code>--force</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This command will remove all PatchMon files, configuration,
|
||||
and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -155,6 +155,20 @@ const Automation = () => {
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Daily at 3 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(3, 0, 0, 0);
|
||||
return tomorrow.toLocaleString([], {
|
||||
hour12: true,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -188,6 +202,13 @@ const Automation = () => {
|
||||
tomorrow.setHours(2, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Daily at 3 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(3, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -220,6 +241,8 @@ const Automation = () => {
|
||||
endpoint = "/automation/trigger/session-cleanup";
|
||||
} else if (jobType === "orphaned-repos") {
|
||||
endpoint = "/automation/trigger/orphaned-repo-cleanup";
|
||||
} else if (jobType === "orphaned-packages") {
|
||||
endpoint = "/automation/trigger/orphaned-package-cleanup";
|
||||
} else if (jobType === "agent-collection") {
|
||||
endpoint = "/automation/trigger/agent-collection";
|
||||
}
|
||||
@@ -531,6 +554,10 @@ const Automation = () => {
|
||||
automation.queue.includes("orphaned-repo")
|
||||
) {
|
||||
triggerManualJob("orphaned-repos");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
|
||||
@@ -200,6 +200,8 @@ const Dashboard = () => {
|
||||
data: packageTrendsData,
|
||||
isLoading: packageTrendsLoading,
|
||||
error: _packageTrendsError,
|
||||
refetch: refetchPackageTrends,
|
||||
isFetching: packageTrendsFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
|
||||
queryFn: () => {
|
||||
@@ -771,6 +773,20 @@ const Dashboard = () => {
|
||||
Package Trends Over Time
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchPackageTrends()}
|
||||
disabled={packageTrendsFetching}
|
||||
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"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${packageTrendsFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
{/* Period Selector */}
|
||||
<select
|
||||
value={packageTrendsPeriod}
|
||||
@@ -1191,6 +1207,13 @@ const Dashboard = () => {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
if (value === null || value === undefined) {
|
||||
return `${context.dataset.label}: No data`;
|
||||
}
|
||||
return `${context.dataset.label}: ${value}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1411,7 +1434,6 @@ const Dashboard = () => {
|
||||
title="Customize dashboard layout"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1423,7 +1445,6 @@ const Dashboard = () => {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,11 +30,13 @@ import {
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import InlineEdit from "../components/InlineEdit";
|
||||
import InlineMultiGroupEdit from "../components/InlineMultiGroupEdit";
|
||||
import {
|
||||
adminHostsAPI,
|
||||
dashboardAPI,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
hostGroupsAPI,
|
||||
repositoryAPI,
|
||||
settingsAPI,
|
||||
} from "../utils/api";
|
||||
@@ -101,7 +103,8 @@ const HostDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (_err) => {
|
||||
eventSource.onerror = (_error) => {
|
||||
console.log(`[SSE] Connection error for ${host.api_id}, retrying...`);
|
||||
eventSource?.close();
|
||||
|
||||
// Automatic reconnection after 5 seconds
|
||||
@@ -136,6 +139,14 @@ const HostDetail = () => {
|
||||
enabled: !!hostId,
|
||||
});
|
||||
|
||||
// Fetch host groups for multi-select
|
||||
const { data: hostGroups } = useQuery({
|
||||
queryKey: ["host-groups"],
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Tab change handler
|
||||
const handleTabChange = (tabName) => {
|
||||
setActiveTab(tabName);
|
||||
@@ -186,6 +197,15 @@ const HostDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupsMutation = useMutation({
|
||||
mutationFn: ({ hostId, groupIds }) =>
|
||||
adminHostsAPI.updateGroups(hostId, groupIds).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
});
|
||||
|
||||
const updateNotesMutation = useMutation({
|
||||
mutationFn: ({ hostId, notes }) =>
|
||||
adminHostsAPI.updateNotes(hostId, notes).then((res) => res.data),
|
||||
@@ -601,20 +621,30 @@ const HostDetail = () => {
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||
Host Group
|
||||
Host Groups
|
||||
</p>
|
||||
{host.host_groups ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: host.host_groups.color }}
|
||||
>
|
||||
{host.host_groups.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
|
||||
Ungrouped
|
||||
</span>
|
||||
)}
|
||||
{/* Extract group IDs from the new many-to-many structure */}
|
||||
{(() => {
|
||||
const groupIds =
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups.id,
|
||||
) || [];
|
||||
return (
|
||||
<InlineMultiGroupEdit
|
||||
key={`${host.id}-${groupIds.join(",")}`}
|
||||
value={groupIds}
|
||||
onSave={(newGroupIds) =>
|
||||
updateHostGroupsMutation.mutate({
|
||||
hostId: host.id,
|
||||
groupIds: newGroupIds,
|
||||
})
|
||||
}
|
||||
options={hostGroups || []}
|
||||
placeholder="Select groups..."
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1275,8 +1305,10 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("quick-install");
|
||||
const [forceInstall, setForceInstall] = useState(false);
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const apiIdInputId = useId();
|
||||
const apiKeyInputId = useId();
|
||||
const architectureSelectId = useId();
|
||||
|
||||
const { data: serverUrlData } = useQuery({
|
||||
queryKey: ["serverUrl"],
|
||||
@@ -1296,10 +1328,13 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
// Helper function to build installation URL with optional force flag
|
||||
// Helper function to build installation URL with optional force flag and architecture
|
||||
const getInstallUrl = () => {
|
||||
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
|
||||
return forceInstall ? `${baseUrl}?force=true` : baseUrl;
|
||||
const params = new URLSearchParams();
|
||||
if (forceInstall) params.append("force", "true");
|
||||
params.append("arch", architecture);
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
@@ -1415,6 +1450,29 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Architecture Selection */}
|
||||
<div className="mb-3">
|
||||
<label
|
||||
htmlFor={architectureSelectId}
|
||||
className="block text-sm font-medium text-primary-800 dark:text-primary-200 mb-2"
|
||||
>
|
||||
Target Architecture
|
||||
</label>
|
||||
<select
|
||||
id={architectureSelectId}
|
||||
value={architecture}
|
||||
onChange={(e) => setArchitecture(e.target.value)}
|
||||
className="px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm text-secondary-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="amd64">AMD64 (x86_64) - Default</option>
|
||||
<option value="386">386 (i386) - 32-bit</option>
|
||||
<option value="arm64">ARM64 (aarch64) - ARM</option>
|
||||
</select>
|
||||
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
|
||||
Select the architecture of the target host
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1471,12 +1529,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
2. Download and Install Agent Script
|
||||
2. Download and Install Agent Binary
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`}
|
||||
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1484,7 +1542,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
|
||||
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1502,7 +1560,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
|
||||
value={`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1510,7 +1568,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
|
||||
`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1528,7 +1586,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo /usr/local/bin/patchmon-agent.sh test"
|
||||
value="sudo /usr/local/bin/patchmon-agent ping"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1536,7 +1594,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo /usr/local/bin/patchmon-agent.sh test",
|
||||
"sudo /usr/local/bin/patchmon-agent ping",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1554,7 +1612,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo /usr/local/bin/patchmon-agent.sh update"
|
||||
value="sudo /usr/local/bin/patchmon-agent report"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1562,7 +1620,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo /usr/local/bin/patchmon-agent.sh update",
|
||||
"sudo /usr/local/bin/patchmon-agent report",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1575,12 +1633,33 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
6. Setup Crontab (Optional)
|
||||
6. Create Systemd Service File
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`}
|
||||
value={`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=PatchMon Agent Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/patchmon-agent serve
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/etc/patchmon
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=patchmon-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1588,7 +1667,28 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`,
|
||||
`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=PatchMon Agent Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/patchmon-agent serve
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/etc/patchmon
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=patchmon-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1598,6 +1698,64 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
7. Enable and Start Service
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
|
||||
This will start the agent service and establish WebSocket
|
||||
connection for real-time communication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
8. Verify Service Status
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo systemctl status patchmon-agent"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard("sudo systemctl status patchmon-agent")
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
|
||||
Check that the service is running and WebSocket connection
|
||||
is established
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import InlineEdit from "../components/InlineEdit";
|
||||
import InlineGroupEdit from "../components/InlineGroupEdit";
|
||||
import InlineMultiGroupEdit from "../components/InlineMultiGroupEdit";
|
||||
import InlineToggle from "../components/InlineToggle";
|
||||
import {
|
||||
adminHostsAPI,
|
||||
@@ -35,14 +35,14 @@ import {
|
||||
formatRelativeTime,
|
||||
hostGroupsAPI,
|
||||
} from "../utils/api";
|
||||
import { OSIcon } from "../utils/osIcons.jsx";
|
||||
import { getOSDisplayName, OSIcon } from "../utils/osIcons.jsx";
|
||||
|
||||
// Add Host Modal Component
|
||||
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const friendlyNameId = useId();
|
||||
const [formData, setFormData] = useState({
|
||||
friendly_name: "",
|
||||
hostGroupId: "",
|
||||
hostGroupIds: [], // Changed to array for multiple selection
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -65,7 +65,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const response = await adminHostsAPI.create(formData);
|
||||
console.log("Host created successfully:", formData.friendly_name);
|
||||
onSuccess(response.data);
|
||||
setFormData({ friendly_name: "", hostGroupId: "" });
|
||||
setFormData({ friendly_name: "", hostGroupIds: [] });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Full error object:", err);
|
||||
@@ -135,68 +135,56 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
|
||||
Host Group
|
||||
Host Groups
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* No Group Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: "" })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === ""
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">No Group</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Ungrouped
|
||||
</div>
|
||||
{formData.hostGroupId === "" && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{/* Host Group Options */}
|
||||
{hostGroups?.map((group) => (
|
||||
<button
|
||||
<label
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData({ ...formData, hostGroupId: group.id })
|
||||
}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === group.id
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
className={`flex items-center gap-3 p-3 border-2 rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
formData.hostGroupIds.includes(group.id)
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1 w-full justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hostGroupIds.includes(group.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setFormData({
|
||||
...formData,
|
||||
hostGroupIds: [...formData.hostGroupIds, group.id],
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
hostGroupIds: formData.hostGroupIds.filter(
|
||||
(id) => id !== group.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{group.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
></div>
|
||||
)}
|
||||
<div className="text-xs font-medium truncate max-w-full">
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Group
|
||||
</div>
|
||||
{formData.hostGroupId === group.id && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Optional: Assign this host to a group for better organization.
|
||||
Optional: Select one or more groups to assign this host to for
|
||||
better organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +329,7 @@ const Hosts = () => {
|
||||
visible: true,
|
||||
order: 8,
|
||||
},
|
||||
{ id: "ws_status", label: "Online", visible: true, order: 9 },
|
||||
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
|
||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 11 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 12 },
|
||||
@@ -368,8 +356,11 @@ const Hosts = () => {
|
||||
localStorage.removeItem("hosts-column-config");
|
||||
return defaultConfig;
|
||||
} else {
|
||||
// Use the existing configuration
|
||||
return savedConfig;
|
||||
// Ensure ws_status column is visible in saved config
|
||||
const updatedConfig = savedConfig.map((col) =>
|
||||
col.id === "ws_status" ? { ...col, visible: true } : col,
|
||||
);
|
||||
return updatedConfig;
|
||||
}
|
||||
} catch {
|
||||
// If there's an error parsing the config, clear it and use default
|
||||
@@ -479,11 +470,12 @@ const Hosts = () => {
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.onerror = (_error) => {
|
||||
console.log(`[SSE] Connection error for ${apiId}, retrying...`);
|
||||
es?.close();
|
||||
eventSources.delete(apiId);
|
||||
if (isMounted) {
|
||||
// Retry connection after 5 seconds
|
||||
// Retry connection after 5 seconds with exponential backoff
|
||||
setTimeout(() => connectHost(apiId), 5000);
|
||||
}
|
||||
};
|
||||
@@ -553,7 +545,7 @@ const Hosts = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupMutation = useMutation({
|
||||
const _updateHostGroupMutation = useMutation({
|
||||
mutationFn: ({ hostId, hostGroupId }) => {
|
||||
console.log("updateHostGroupMutation called with:", {
|
||||
hostId,
|
||||
@@ -599,6 +591,46 @@ const Hosts = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupsMutation = useMutation({
|
||||
mutationFn: ({ hostId, groupIds }) => {
|
||||
console.log("updateHostGroupsMutation called with:", {
|
||||
hostId,
|
||||
groupIds,
|
||||
});
|
||||
return adminHostsAPI.updateGroups(hostId, groupIds).then((res) => {
|
||||
console.log("updateGroups API response:", res);
|
||||
return res.data;
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Update the cache with the new host data
|
||||
queryClient.setQueryData(["hosts"], (oldData) => {
|
||||
console.log("Old cache data before update:", oldData);
|
||||
if (!oldData) return oldData;
|
||||
const updatedData = oldData.map((host) => {
|
||||
if (host.id === data.host.id) {
|
||||
console.log(
|
||||
"Updating host in cache:",
|
||||
host.id,
|
||||
"with new data:",
|
||||
data.host,
|
||||
);
|
||||
return data.host;
|
||||
}
|
||||
return host;
|
||||
});
|
||||
console.log("New cache data after update:", updatedData);
|
||||
return updatedData;
|
||||
});
|
||||
|
||||
// Also invalidate to ensure consistency
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("updateHostGroupsMutation error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAutoUpdateMutation = useMutation({
|
||||
mutationFn: ({ hostId, autoUpdate }) =>
|
||||
adminHostsAPI
|
||||
@@ -879,7 +911,7 @@ const Hosts = () => {
|
||||
visible: true,
|
||||
order: 8,
|
||||
},
|
||||
{ id: "ws_status", label: "Online", visible: true, order: 9 },
|
||||
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
|
||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 11 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 12 },
|
||||
@@ -947,27 +979,33 @@ const Hosts = () => {
|
||||
{host.ip || "N/A"}
|
||||
</div>
|
||||
);
|
||||
case "group":
|
||||
case "group": {
|
||||
// Extract group IDs from the new many-to-many structure
|
||||
const groupIds =
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups.id,
|
||||
) || [];
|
||||
return (
|
||||
<InlineGroupEdit
|
||||
key={`${host.id}-${host.host_groups?.id || "ungrouped"}-${host.host_groups?.name || "ungrouped"}`}
|
||||
value={host.host_groups?.id}
|
||||
onSave={(newGroupId) =>
|
||||
updateHostGroupMutation.mutate({
|
||||
<InlineMultiGroupEdit
|
||||
key={`${host.id}-${groupIds.join(",")}`}
|
||||
value={groupIds}
|
||||
onSave={(newGroupIds) =>
|
||||
updateHostGroupsMutation.mutate({
|
||||
hostId: host.id,
|
||||
hostGroupId: newGroupId,
|
||||
groupIds: newGroupIds,
|
||||
})
|
||||
}
|
||||
options={hostGroups || []}
|
||||
placeholder="Select group..."
|
||||
placeholder="Select groups..."
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "os":
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
|
||||
<OSIcon osType={host.os_type} className="h-4 w-4" />
|
||||
<span>{host.os_type}</span>
|
||||
<span>{getOSDisplayName(host.os_type)}</span>
|
||||
</div>
|
||||
);
|
||||
case "os_version":
|
||||
@@ -1000,24 +1038,30 @@ const Hosts = () => {
|
||||
const wsStatus = wsStatusMap[host.api_id];
|
||||
if (!wsStatus) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
...
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full mr-1.5"></div>
|
||||
Unknown
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase ${
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
wsStatus.connected
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 animate-pulse"
|
||||
? "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"
|
||||
}`}
|
||||
title={
|
||||
wsStatus.connected
|
||||
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS"}`
|
||||
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS (insecure)"}`
|
||||
: "Agent not connected"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-1.5 ${
|
||||
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`}
|
||||
></div>
|
||||
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
|
||||
</span>
|
||||
);
|
||||
@@ -1616,6 +1660,11 @@ const Hosts = () => {
|
||||
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
|
||||
{column.label}
|
||||
</div>
|
||||
) : column.id === "ws_status" ? (
|
||||
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
|
||||
<Wifi className="h-3 w-3" />
|
||||
{column.label}
|
||||
</div>
|
||||
) : column.id === "status" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1964,9 +2013,10 @@ const ColumnSettingsModal = ({
|
||||
};
|
||||
|
||||
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 shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<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 shadow-xl max-w-lg w-full max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Column Settings
|
||||
@@ -1979,14 +2029,14 @@ const ColumnSettingsModal = ({
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||
Drag to reorder columns or toggle visibility
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Scrollable content */}
|
||||
<div className="px-6 py-4 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{columnConfig.map((column, index) => (
|
||||
<button
|
||||
key={column.id}
|
||||
@@ -2003,22 +2053,22 @@ const ColumnSettingsModal = ({
|
||||
// Focus handling for keyboard users
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-move w-full text-left ${
|
||||
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
|
||||
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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{column.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
className={`p-1 rounded ${
|
||||
className={`p-1 rounded transition-colors flex-shrink-0 ${
|
||||
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"
|
||||
@@ -2033,8 +2083,11 @@ const ColumnSettingsModal = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-secondary-200 dark:border-secondary-600 flex-shrink-0">
|
||||
<div className="flex justify-between">
|
||||
<button type="button" onClick={onReset} className="btn-outline">
|
||||
Reset to Default
|
||||
</button>
|
||||
|
||||
@@ -120,7 +120,7 @@ const Settings = () => {
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const getCurlFlags = () => {
|
||||
const _getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
@@ -1155,28 +1155,39 @@ const Settings = () => {
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-2">
|
||||
<p className="mb-3">
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
curl {getCurlFlags()} {window.location.origin}
|
||||
/api/v1/hosts/remove | sudo bash
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>,{" "}
|
||||
<code>--remove-all</code>, <code>--force</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
|
||||
navigator.clipboard.writeText(command);
|
||||
// You could add a toast notification here
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This will remove all PatchMon files,
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,11 @@ export const dashboardAPI = {
|
||||
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getPackageSpikeAnalysis: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/package-spike-analysis${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getRecentUsers: () => api.get("/dashboard/recent-users"),
|
||||
getRecentCollection: () => api.get("/dashboard/recent-collection"),
|
||||
};
|
||||
@@ -82,8 +87,12 @@ export const adminHostsAPI = {
|
||||
api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||
updateGroup: (hostId, hostGroupId) =>
|
||||
api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||
updateGroups: (hostId, groupIds) =>
|
||||
api.put(`/hosts/${hostId}/groups`, { groupIds }),
|
||||
bulkUpdateGroup: (hostIds, hostGroupId) =>
|
||||
api.put("/hosts/bulk/group", { hostIds, hostGroupId }),
|
||||
bulkUpdateGroups: (hostIds, groupIds) =>
|
||||
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
|
||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
updateFriendlyName: (hostId, friendlyName) =>
|
||||
|
||||
@@ -1,43 +1,104 @@
|
||||
import { Monitor, Server } from "lucide-react";
|
||||
import { DiWindows } from "react-icons/di";
|
||||
// Import OS icons from react-icons
|
||||
// Import OS icons from react-icons Simple Icons - using only confirmed available icons
|
||||
import {
|
||||
SiAlmalinux,
|
||||
SiAlpinelinux,
|
||||
SiArchlinux,
|
||||
SiCentos,
|
||||
SiDebian,
|
||||
SiDeepin,
|
||||
SiElementary,
|
||||
SiFedora,
|
||||
SiGentoo,
|
||||
SiKalilinux,
|
||||
SiLinux,
|
||||
SiLinuxmint,
|
||||
SiMacos,
|
||||
SiManjaro,
|
||||
SiOpensuse,
|
||||
SiOracle,
|
||||
SiParrotsecurity,
|
||||
SiPopos,
|
||||
SiRedhat,
|
||||
SiRockylinux,
|
||||
SiSlackware,
|
||||
SiSolus,
|
||||
SiSuse,
|
||||
SiTails,
|
||||
SiUbuntu,
|
||||
SiZorin,
|
||||
} from "react-icons/si";
|
||||
|
||||
/**
|
||||
* OS Icon mapping utility
|
||||
* Maps operating system types to appropriate react-icons components
|
||||
* Now uses specific icons based on actual OS names from /etc/os-release
|
||||
*/
|
||||
export const getOSIcon = (osType) => {
|
||||
if (!osType) return Monitor;
|
||||
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions with authentic react-icons
|
||||
if (os.includes("ubuntu")) return SiUbuntu;
|
||||
// Ubuntu and Ubuntu variants
|
||||
if (os.includes("ubuntu")) {
|
||||
// For Ubuntu variants, use generic Ubuntu icon as fallback
|
||||
return SiUbuntu;
|
||||
}
|
||||
|
||||
// Pop!_OS
|
||||
if (os.includes("pop") || os.includes("pop!_os")) return SiPopos;
|
||||
|
||||
// Linux Mint
|
||||
if (os.includes("mint") || os.includes("linuxmint")) return SiLinuxmint;
|
||||
|
||||
// Elementary OS
|
||||
if (os.includes("elementary")) return SiElementary;
|
||||
|
||||
// Debian
|
||||
if (os.includes("debian")) return SiDebian;
|
||||
if (
|
||||
os.includes("centos") ||
|
||||
os.includes("rhel") ||
|
||||
os.includes("red hat") ||
|
||||
os.includes("almalinux") ||
|
||||
os.includes("rocky")
|
||||
)
|
||||
return SiCentos;
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return SiLinux; // Use generic Linux icon for Oracle Linux
|
||||
|
||||
// Rocky Linux
|
||||
if (os.includes("rocky")) return SiRockylinux;
|
||||
|
||||
// AlmaLinux
|
||||
if (os.includes("alma") || os.includes("almalinux")) return SiAlmalinux;
|
||||
|
||||
// CentOS
|
||||
if (os.includes("centos")) return SiCentos;
|
||||
|
||||
// Red Hat Enterprise Linux
|
||||
if (os.includes("rhel") || os.includes("red hat")) return SiRedhat;
|
||||
|
||||
// Fedora
|
||||
if (os.includes("fedora")) return SiFedora;
|
||||
|
||||
// Oracle Linux
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return SiOracle;
|
||||
|
||||
// SUSE distributions
|
||||
if (os.includes("opensuse")) return SiOpensuse;
|
||||
if (os.includes("suse")) return SiSuse;
|
||||
|
||||
// Arch-based distributions
|
||||
if (os.includes("arch")) return SiArchlinux;
|
||||
if (os.includes("manjaro")) return SiManjaro;
|
||||
if (os.includes("endeavour") || os.includes("endeavouros"))
|
||||
return SiArchlinux; // Fallback to Arch
|
||||
if (os.includes("garuda")) return SiArchlinux; // Fallback to Arch
|
||||
if (os.includes("blackarch")) return SiArchlinux; // Fallback to Arch
|
||||
|
||||
// Other distributions
|
||||
if (os.includes("alpine")) return SiAlpinelinux;
|
||||
if (os.includes("suse") || os.includes("opensuse")) return SiLinux; // SUSE uses generic Linux icon
|
||||
if (os.includes("gentoo")) return SiGentoo;
|
||||
if (os.includes("slackware")) return SiSlackware;
|
||||
if (os.includes("zorin")) return SiZorin;
|
||||
if (os.includes("deepin")) return SiDeepin;
|
||||
if (os.includes("solus")) return SiSolus;
|
||||
if (os.includes("tails")) return SiTails;
|
||||
if (os.includes("parrot")) return SiParrotsecurity;
|
||||
if (os.includes("kali")) return SiKalilinux;
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes("linux")) return SiLinux;
|
||||
@@ -70,27 +131,83 @@ export const getOSColor = (osType) => {
|
||||
/**
|
||||
* OS Display name utility
|
||||
* Provides clean, formatted OS names for display
|
||||
* Updated to handle more distributions from /etc/os-release
|
||||
*/
|
||||
export const getOSDisplayName = (osType) => {
|
||||
if (!osType) return "Unknown";
|
||||
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions
|
||||
if (os.includes("ubuntu")) return "Ubuntu";
|
||||
// Ubuntu and variants
|
||||
if (os.includes("ubuntu")) {
|
||||
if (os.includes("kubuntu")) return "Kubuntu";
|
||||
if (os.includes("lubuntu")) return "Lubuntu";
|
||||
if (os.includes("xubuntu")) return "Xubuntu";
|
||||
if (os.includes("ubuntu mate") || os.includes("ubuntumate"))
|
||||
return "Ubuntu MATE";
|
||||
if (os.includes("ubuntu budgie") || os.includes("ubuntubudgie"))
|
||||
return "Ubuntu Budgie";
|
||||
if (os.includes("ubuntu studio") || os.includes("ubuntustudio"))
|
||||
return "Ubuntu Studio";
|
||||
if (os.includes("ubuntu kylin") || os.includes("ubuntukylin"))
|
||||
return "Ubuntu Kylin";
|
||||
return "Ubuntu";
|
||||
}
|
||||
|
||||
// Pop!_OS
|
||||
if (os.includes("pop") || os.includes("pop!_os")) return "Pop!_OS";
|
||||
|
||||
// Linux Mint
|
||||
if (os.includes("mint") || os.includes("linuxmint")) return "Linux Mint";
|
||||
|
||||
// Elementary OS
|
||||
if (os.includes("elementary")) return "Elementary OS";
|
||||
|
||||
// Debian
|
||||
if (os.includes("debian")) return "Debian";
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
if (os.includes("almalinux")) return "AlmaLinux";
|
||||
|
||||
// Rocky Linux
|
||||
if (os.includes("rocky")) return "Rocky Linux";
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return "Oracle Linux";
|
||||
|
||||
// AlmaLinux
|
||||
if (os.includes("alma") || os.includes("almalinux")) return "AlmaLinux";
|
||||
|
||||
// CentOS
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
|
||||
// Red Hat Enterprise Linux
|
||||
if (os.includes("rhel") || os.includes("red hat"))
|
||||
return "Red Hat Enterprise Linux";
|
||||
|
||||
// Fedora
|
||||
if (os.includes("fedora")) return "Fedora";
|
||||
if (os.includes("arch")) return "Arch Linux";
|
||||
if (os.includes("suse")) return "SUSE Linux";
|
||||
|
||||
// Oracle Linux
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return "Oracle Linux";
|
||||
|
||||
// SUSE distributions
|
||||
if (os.includes("opensuse")) return "openSUSE";
|
||||
if (os.includes("suse")) return "SUSE Linux";
|
||||
|
||||
// Arch-based distributions
|
||||
if (os.includes("arch")) return "Arch Linux";
|
||||
if (os.includes("manjaro")) return "Manjaro";
|
||||
if (os.includes("endeavour") || os.includes("endeavouros"))
|
||||
return "EndeavourOS";
|
||||
if (os.includes("garuda")) return "Garuda Linux";
|
||||
if (os.includes("blackarch")) return "BlackArch Linux";
|
||||
|
||||
// Other distributions
|
||||
if (os.includes("alpine")) return "Alpine Linux";
|
||||
if (os.includes("gentoo")) return "Gentoo";
|
||||
if (os.includes("slackware")) return "Slackware";
|
||||
if (os.includes("zorin")) return "Zorin OS";
|
||||
if (os.includes("deepin")) return "Deepin";
|
||||
if (os.includes("solus")) return "Solus";
|
||||
if (os.includes("tails")) return "Tails";
|
||||
if (os.includes("parrot")) return "Parrot Security";
|
||||
if (os.includes("kali")) return "Kali Linux";
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes("linux")) return "Linux";
|
||||
|
||||
Reference in New Issue
Block a user