import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, CheckSquare, ChevronDown, Clock, Columns, ExternalLink, Eye as EyeIcon, EyeOff as EyeOffIcon, Filter, GripVertical, Plus, RefreshCw, RotateCcw, Search, Server, Square, Trash2, Users, Wifi, X, } from "lucide-react"; import { useEffect, useId, useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import InlineEdit from "../components/InlineEdit"; import InlineMultiGroupEdit from "../components/InlineMultiGroupEdit"; import InlineToggle from "../components/InlineToggle"; import { adminHostsAPI, dashboardAPI, formatRelativeTime, hostGroupsAPI, } from "../utils/api"; 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: "", hostGroupIds: [], // Changed to array for multiple selection }); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); // Fetch host groups for selection const { data: hostGroups } = useQuery({ queryKey: ["hostGroups"], queryFn: () => hostGroupsAPI.list().then((res) => res.data), enabled: isOpen, }); const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); setError(""); console.log("Creating host:", formData.friendly_name); try { const response = await adminHostsAPI.create(formData); console.log("Host created successfully:", formData.friendly_name); onSuccess(response.data); setFormData({ friendly_name: "", hostGroupIds: [] }); onClose(); } catch (err) { console.error("Full error object:", err); console.error("Error response:", err.response); let errorMessage = "Failed to create host"; if (err.response?.data?.errors) { // Validation errors errorMessage = err.response.data.errors.map((e) => e.msg).join(", "); } else if (err.response?.data?.error) { // Single error message errorMessage = err.response.data.error; } else if (err.message) { // Network or other error errorMessage = err.message; } setError(errorMessage); } finally { setIsSubmitting(false); } }; if (!isOpen) return null; return (

Add New Host

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" placeholder="server.example.com" />

System information (OS, IP, architecture) will be automatically detected when the agent connects.

Host Groups
{/* Host Group Options */} {hostGroups?.map((group) => ( ))}

Optional: Select one or more groups to assign this host to for better organization.

{error && (

{error}

)}
); }; const Hosts = () => { const hostGroupFilterId = useId(); const statusFilterId = useId(); const osFilterId = useId(); const [showAddModal, setShowAddModal] = useState(false); const [selectedHosts, setSelectedHosts] = useState([]); const [showBulkAssignModal, setShowBulkAssignModal] = useState(false); const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); const [searchParams] = useSearchParams(); const navigate = useNavigate(); // Table state const [searchTerm, setSearchTerm] = useState(""); const [sortField, setSortField] = useState("hostname"); const [sortDirection, setSortDirection] = useState("asc"); const [groupFilter, setGroupFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); const [osFilter, setOsFilter] = useState("all"); const [showFilters, setShowFilters] = useState(false); const [groupBy, setGroupBy] = useState("none"); const [showColumnSettings, setShowColumnSettings] = useState(false); const [hideStale, setHideStale] = useState(false); // Handle URL filter parameters useEffect(() => { const filter = searchParams.get("filter"); const showFiltersParam = searchParams.get("showFilters"); const osFilterParam = searchParams.get("osFilter"); const groupParam = searchParams.get("group"); if (filter === "needsUpdates") { setShowFilters(true); setStatusFilter("all"); // We'll filter hosts with updates > 0 in the filtering logic } else if (filter === "inactive") { setShowFilters(true); setStatusFilter("inactive"); // We'll filter hosts with inactive status in the filtering logic } else if (filter === "upToDate") { setShowFilters(true); setStatusFilter("active"); // We'll filter hosts that are up to date in the filtering logic } else if (filter === "stale") { setShowFilters(true); setStatusFilter("all"); // We'll filter hosts that are stale in the filtering logic } else if (showFiltersParam === "true") { setShowFilters(true); } // Handle OS filter parameter if (osFilterParam) { setShowFilters(true); setOsFilter(osFilterParam); } // Handle group filter parameter if (groupParam) { setShowFilters(true); setGroupFilter(groupParam); } // Handle add host action from navigation const action = searchParams.get("action"); if (action === "add") { setShowAddModal(true); // Remove the action parameter from URL without triggering a page reload const newSearchParams = new URLSearchParams(searchParams); newSearchParams.delete("action"); navigate( `/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`, { replace: true, }, ); } // Handle selected hosts from packages page const selected = searchParams.get("selected"); if (selected) { const hostIds = selected.split(",").filter(Boolean); setSelectedHosts(hostIds); // Remove the selected parameter from URL without triggering a page reload const newSearchParams = new URLSearchParams(searchParams); newSearchParams.delete("selected"); navigate( `/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`, { replace: true, }, ); } }, [searchParams, navigate]); // Column configuration const [columnConfig, setColumnConfig] = useState(() => { const defaultConfig = [ { id: "select", label: "Select", visible: true, order: 0 }, { id: "host", label: "Friendly Name", visible: true, order: 1 }, { id: "hostname", label: "System Hostname", visible: true, order: 2 }, { id: "ip", label: "IP Address", visible: false, order: 3 }, { id: "group", label: "Group", visible: true, order: 4 }, { id: "os", label: "OS", visible: true, order: 5 }, { id: "os_version", label: "OS Version", visible: false, order: 6 }, { id: "agent_version", label: "Agent Version", visible: true, order: 7 }, { id: "auto_update", label: "Agent Auto-Update", visible: true, order: 8, }, { id: "ws_status", label: "Connection", visible: true, order: 9 }, { id: "status", label: "Status", visible: true, order: 10 }, { id: "needs_reboot", label: "Reboot", visible: true, order: 11 }, { id: "updates", label: "Updates", visible: true, order: 12 }, { id: "security_updates", label: "Security Updates", visible: true, order: 13, }, { id: "notes", label: "Notes", visible: false, order: 14 }, { id: "last_update", label: "Last Update", visible: true, order: 15 }, { id: "actions", label: "Actions", visible: true, order: 16 }, ]; const saved = localStorage.getItem("hosts-column-config"); if (saved) { try { const savedConfig = JSON.parse(saved); // Check if we have old camelCase column IDs that need to be migrated const hasOldColumns = savedConfig.some( (col) => col.id === "agentVersion" || col.id === "autoUpdate" || col.id === "osVersion" || col.id === "lastUpdate", ); if (hasOldColumns) { // Clear the old configuration and use the default snake_case configuration localStorage.removeItem("hosts-column-config"); return defaultConfig; } else { // Merge saved config with defaults to handle new columns // This preserves user's visibility preferences while adding new columns const mergedConfig = defaultConfig.map((defaultCol) => { const savedCol = savedConfig.find( (col) => col.id === defaultCol.id, ); if (savedCol) { // Use saved visibility preference, but keep default order and label return { ...defaultCol, visible: savedCol.visible, }; } // New column not in saved config, use default return defaultCol; }); // Ensure ws_status column is visible const updatedConfig = mergedConfig.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 localStorage.removeItem("hosts-column-config"); return defaultConfig; } } return defaultConfig; }); const queryClient = useQueryClient(); const { data: hosts, isLoading, error, refetch, isFetching, } = useQuery({ queryKey: ["hosts"], queryFn: () => dashboardAPI.getHosts().then((res) => res.data), staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes refetchOnWindowFocus: false, // Don't refetch when window regains focus }); const { data: hostGroups } = useQuery({ queryKey: ["hostGroups"], queryFn: () => hostGroupsAPI.list().then((res) => res.data), }); // Track WebSocket status for all hosts const [wsStatusMap, setWsStatusMap] = useState({}); // Fetch initial WebSocket status for all hosts useEffect(() => { if (!hosts || hosts.length === 0) return; const token = localStorage.getItem("token"); if (!token) return; // Fetch initial WebSocket status for all hosts // Fetch initial WebSocket status for all hosts const fetchInitialStatus = async () => { const apiIds = hosts .filter((host) => host.api_id) .map((host) => host.api_id); if (apiIds.length === 0) return; try { const response = await fetch( `/api/v1/ws/status?apiIds=${apiIds.join(",")}`, { headers: { Authorization: `Bearer ${token}`, }, }, ); if (response.ok) { const result = await response.json(); setWsStatusMap(result.data); } } catch (_error) { // Silently handle errors } }; fetchInitialStatus(); }, [hosts]); // Subscribe to WebSocket status changes for all hosts via polling (lightweight alternative to SSE) useEffect(() => { if (!hosts || hosts.length === 0) return; const token = localStorage.getItem("token"); if (!token) return; // Use polling instead of SSE to avoid connection pool issues // Poll every 10 seconds instead of 19 persistent connections const pollInterval = setInterval(() => { const apiIds = hosts .filter((host) => host.api_id) .map((host) => host.api_id); if (apiIds.length === 0) return; fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, { headers: { Authorization: `Bearer ${token}`, }, }) .then((response) => response.json()) .then((result) => { if (result.success && result.data) { setWsStatusMap(result.data); } }) .catch(() => { // Silently handle errors }); }, 10000); // Poll every 10 seconds // Cleanup function return () => { clearInterval(pollInterval); }; }, [hosts]); const bulkUpdateGroupMutation = useMutation({ mutationFn: ({ hostIds, groupIds }) => adminHostsAPI.bulkUpdateGroups(hostIds, groupIds), onSuccess: (data) => { console.log("bulkUpdateGroupMutation success:", data); // Update the cache with the new host data if (data?.hosts) { queryClient.setQueryData(["hosts"], (oldData) => { if (!oldData) return oldData; return oldData.map((host) => { const updatedHost = data.hosts.find((h) => h.id === host.id); if (updatedHost) { return updatedHost; } return host; }); }); } // Also invalidate to ensure consistency queryClient.invalidateQueries(["hosts"]); setSelectedHosts([]); setShowBulkAssignModal(false); }, }); const updateFriendlyNameMutation = useMutation({ mutationFn: ({ hostId, friendlyName }) => adminHostsAPI .updateFriendlyName(hostId, friendlyName) .then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["hosts"]); }, }); const _updateHostGroupMutation = useMutation({ mutationFn: ({ hostId, hostGroupId }) => { console.log("updateHostGroupMutation called with:", { hostId, hostGroupId, }); return adminHostsAPI.updateGroup(hostId, hostGroupId).then((res) => { console.log("updateGroup 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, ); // Host already has host_group_memberships from backend const updatedHost = { ...data.host, }; console.log("Updated host in cache:", updatedHost); return updatedHost; } return host; }); console.log("New cache data after update:", updatedData); return updatedData; }); // Also invalidate to ensure consistency queryClient.invalidateQueries(["hosts"]); }, onError: (error) => { console.error("updateHostGroupMutation error:", error); }, }); 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 .toggleAutoUpdate(hostId, autoUpdate) .then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["hosts"]); }, }); const bulkDeleteMutation = useMutation({ mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds), onSuccess: (data) => { console.log("Bulk delete success:", data); queryClient.invalidateQueries(["hosts"]); setSelectedHosts([]); setShowBulkDeleteModal(false); }, onError: (error) => { console.error("Bulk delete error:", error); }, }); // Helper functions for bulk selection const handleSelectHost = (hostId) => { setSelectedHosts((prev) => prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId], ); }; const handleSelectAll = () => { if (selectedHosts.length === hosts.length) { setSelectedHosts([]); } else { setSelectedHosts(hosts.map((host) => host.id)); } }; const handleBulkAssign = (groupIds) => { bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, groupIds }); }; const handleBulkDelete = () => { bulkDeleteMutation.mutate(selectedHosts); }; // Table filtering and sorting logic const filteredAndSortedHosts = useMemo(() => { if (!hosts) return []; const filtered = hosts.filter((host) => { // Search filter const matchesSearch = searchTerm === "" || host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) || host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) || host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) || host.notes?.toLowerCase().includes(searchTerm.toLowerCase()); // Group filter - handle multiple groups per host const memberships = host.host_group_memberships || []; const matchesGroup = groupFilter === "all" || (groupFilter === "ungrouped" && memberships.length === 0) || (groupFilter !== "ungrouped" && memberships.some( (membership) => membership.host_groups?.id === groupFilter, )); // Status filter const matchesStatus = statusFilter === "all" || (host.effectiveStatus || host.status) === statusFilter; // OS filter const matchesOs = osFilter === "all" || host.os_type?.toLowerCase() === osFilter.toLowerCase(); // URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, offline hosts, or reboot required const filter = searchParams.get("filter"); const rebootParam = searchParams.get("reboot"); const matchesUrlFilter = (filter !== "needsUpdates" || (host.updatesCount && host.updatesCount > 0)) && (filter !== "inactive" || (host.effectiveStatus || host.status) === "inactive") && (filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) && (filter !== "stale" || host.isStale) && (filter !== "offline" || wsStatusMap[host.api_id]?.connected !== true) && (!rebootParam || host.needs_reboot === true); // Hide stale filter const matchesHideStale = !hideStale || !host.isStale; return ( matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale ); }); // Sorting filtered.sort((a, b) => { let aValue, bValue; switch (sortField) { case "friendlyName": aValue = a.friendly_name.toLowerCase(); bValue = b.friendly_name.toLowerCase(); break; case "hostname": aValue = a.hostname?.toLowerCase() || "zzz_no_hostname"; bValue = b.hostname?.toLowerCase() || "zzz_no_hostname"; break; case "ip": aValue = a.ip?.toLowerCase() || "zzz_no_ip"; bValue = b.ip?.toLowerCase() || "zzz_no_ip"; break; case "group": { // Handle multiple groups per host - use first group alphabetically for sorting const aGroups = a.host_group_memberships || []; const bGroups = b.host_group_memberships || []; if (aGroups.length === 0) { aValue = "zzz_ungrouped"; } else { const aGroupNames = aGroups .map((m) => m.host_groups?.name || "") .filter((name) => name) .sort(); aValue = aGroupNames[0] || "zzz_ungrouped"; } if (bGroups.length === 0) { bValue = "zzz_ungrouped"; } else { const bGroupNames = bGroups .map((m) => m.host_groups?.name || "") .filter((name) => name) .sort(); bValue = bGroupNames[0] || "zzz_ungrouped"; } break; } case "os": aValue = a.os_type?.toLowerCase() || "zzz_unknown"; bValue = b.os_type?.toLowerCase() || "zzz_unknown"; break; case "os_version": aValue = a.os_version?.toLowerCase() || "zzz_unknown"; bValue = b.os_version?.toLowerCase() || "zzz_unknown"; break; case "agent_version": aValue = a.agent_version?.toLowerCase() || "zzz_no_version"; bValue = b.agent_version?.toLowerCase() || "zzz_no_version"; break; case "status": aValue = a.effectiveStatus || a.status; bValue = b.effectiveStatus || b.status; break; case "updates": aValue = a.updatesCount || 0; bValue = b.updatesCount || 0; break; case "security_updates": aValue = a.securityUpdatesCount || 0; bValue = b.securityUpdatesCount || 0; break; case "needs_reboot": // Sort by boolean: false (0) comes before true (1) aValue = a.needs_reboot ? 1 : 0; bValue = b.needs_reboot ? 1 : 0; break; case "last_update": aValue = new Date(a.last_update); bValue = new Date(b.last_update); break; case "notes": aValue = (a.notes || "").toLowerCase(); bValue = (b.notes || "").toLowerCase(); break; default: aValue = a[sortField]; bValue = b[sortField]; } if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; return 0; }); return filtered; }, [ hosts, searchTerm, groupFilter, statusFilter, osFilter, sortField, sortDirection, searchParams, hideStale, wsStatusMap, ]); // Get unique OS types from hosts for dynamic dropdown const uniqueOsTypes = useMemo(() => { if (!hosts) return []; const osTypes = new Set(); hosts.forEach((host) => { if (host.os_type) { osTypes.add(host.os_type); } }); return Array.from(osTypes).sort(); }, [hosts]); // Group hosts by selected field const groupedHosts = useMemo(() => { if (groupBy === "none") { return { "All Hosts": filteredAndSortedHosts }; } const groups = {}; filteredAndSortedHosts.forEach((host) => { if (groupBy === "group") { // Handle multiple groups per host const memberships = host.host_group_memberships || []; if (memberships.length === 0) { // Host has no groups, add to "Ungrouped" if (!groups.Ungrouped) { groups.Ungrouped = []; } groups.Ungrouped.push(host); } else { // Host has one or more groups, add to each group memberships.forEach((membership) => { const groupName = membership.host_groups?.name || "Unknown"; if (!groups[groupName]) { groups[groupName] = []; } groups[groupName].push(host); }); } } else { // Other grouping types (status, os, etc.) let groupKey; switch (groupBy) { case "status": groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1); break; case "os": groupKey = host.os_type || "Unknown"; break; default: groupKey = "All Hosts"; } if (!groups[groupKey]) { groups[groupKey] = []; } groups[groupKey].push(host); } }); return groups; }, [filteredAndSortedHosts, groupBy]); const handleSort = (field) => { if (sortField === field) { setSortDirection(sortDirection === "asc" ? "desc" : "asc"); } else { setSortField(field); setSortDirection("asc"); } }; const getSortIcon = (field) => { if (sortField !== field) return ; return sortDirection === "asc" ? ( ) : ( ); }; // Column management functions const updateColumnConfig = (newConfig) => { setColumnConfig(newConfig); localStorage.setItem("hosts-column-config", JSON.stringify(newConfig)); }; const toggleColumnVisibility = (columnId) => { const newConfig = columnConfig.map((col) => col.id === columnId ? { ...col, visible: !col.visible } : col, ); updateColumnConfig(newConfig); }; const reorderColumns = (fromIndex, toIndex) => { const newConfig = [...columnConfig]; const [movedColumn] = newConfig.splice(fromIndex, 1); newConfig.splice(toIndex, 0, movedColumn); // Update order values const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index, })); updateColumnConfig(updatedConfig); }; const resetColumns = () => { const defaultConfig = [ { id: "select", label: "Select", visible: true, order: 0 }, { id: "host", label: "Friendly Name", visible: true, order: 1 }, { id: "hostname", label: "System Hostname", visible: true, order: 2 }, { id: "ip", label: "IP Address", visible: false, order: 3 }, { id: "group", label: "Group", visible: true, order: 4 }, { id: "os", label: "OS", visible: true, order: 5 }, { id: "os_version", label: "OS Version", visible: false, order: 6 }, { id: "agent_version", label: "Agent Version", visible: true, order: 7 }, { id: "auto_update", label: "Agent Auto-Update", visible: true, order: 8, }, { id: "ws_status", label: "Connection", visible: true, order: 9 }, { id: "status", label: "Status", visible: true, order: 10 }, { id: "needs_reboot", label: "Reboot", visible: true, order: 11 }, { id: "updates", label: "Updates", visible: true, order: 12 }, { id: "security_updates", label: "Security Updates", visible: true, order: 13, }, { id: "notes", label: "Notes", visible: false, order: 14 }, { id: "last_update", label: "Last Update", visible: true, order: 15 }, { id: "actions", label: "Actions", visible: true, order: 16 }, ]; updateColumnConfig(defaultConfig); }; // Get visible columns in order const visibleColumns = columnConfig .filter((col) => col.visible) .sort((a, b) => a.order - b.order); // Helper function to render table cell content const renderCellContent = (column, host) => { switch (column.id) { case "select": return ( ); case "host": return ( updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName, }) } placeholder="Enter friendly name..." maxLength={100} linkTo={`/hosts/${host.id}`} validate={(value) => { if (!value.trim()) return "Friendly name is required"; if (value.trim().length < 1) return "Friendly name must be at least 1 character"; if (value.trim().length > 100) return "Friendly name must be less than 100 characters"; return null; }} className="w-full" /> ); case "hostname": return (
{host.hostname || "N/A"}
); case "ip": return (
{host.ip || "N/A"}
); 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 ( updateHostGroupsMutation.mutate({ hostId: host.id, groupIds: newGroupIds, }) } options={hostGroups || []} placeholder="Select groups..." className="w-full" /> ); } case "os": return (
{getOSDisplayName(host.os_type)}
); case "os_version": return (
{host.os_version || "N/A"}
); case "agent_version": return (
{host.agent_version || "N/A"}
); case "auto_update": return ( toggleAutoUpdateMutation.mutate({ hostId: host.id, autoUpdate: autoUpdate, }) } trueLabel="Yes" falseLabel="No" /> ); case "ws_status": { const wsStatus = wsStatusMap[host.api_id]; if (!wsStatus) { return (
Unknown
); } return (
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
); } case "status": return (
{(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
); case "needs_reboot": return (
{host.needs_reboot ? ( Required ) : ( No )}
); case "updates": return ( ); case "security_updates": return ( ); case "last_update": return (
{formatRelativeTime(host.last_update)}
); case "notes": return (
{host.notes ? (
{host.notes}
) : ( No notes )}
); case "actions": return ( View ); default: return null; } }; const handleHostCreated = (newHost) => { queryClient.invalidateQueries(["hosts"]); // Navigate to host detail page to show credentials and setup instructions navigate(`/hosts/${newHost.hostId}`); }; // Stats card click handlers const handleTotalHostsClick = () => { // Clear all filters to show all hosts setSearchTerm(""); setGroupFilter("all"); setStatusFilter("all"); setOsFilter("all"); setGroupBy("none"); setHideStale(false); setShowFilters(false); // Clear URL parameters to ensure no filters are applied navigate("/hosts", { replace: true }); }; const _handleUpToDateClick = () => { // Filter to show only up-to-date hosts setStatusFilter("active"); setShowFilters(true); // Clear conflicting filters and set upToDate filter const newSearchParams = new URLSearchParams(window.location.search); newSearchParams.set("filter", "upToDate"); newSearchParams.delete("reboot"); // Clear reboot filter when switching to upToDate navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); }; const handleNeedsUpdatesClick = () => { // Filter to show hosts needing updates (regardless of status) setStatusFilter("all"); setShowFilters(true); // Clear conflicting filters and set needsUpdates filter const newSearchParams = new URLSearchParams(window.location.search); newSearchParams.set("filter", "needsUpdates"); newSearchParams.delete("reboot"); // Clear reboot filter when switching to needsUpdates navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); }; const handleConnectionStatusClick = () => { // Filter to show offline hosts (not connected via WebSocket) setStatusFilter("all"); setShowFilters(true); // Clear conflicting filters and set offline filter const newSearchParams = new URLSearchParams(window.location.search); newSearchParams.set("filter", "offline"); newSearchParams.delete("reboot"); // Clear reboot filter when switching to offline navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); }; if (isLoading) { return (
); } if (error) { return (

Error loading hosts

{error.message || "Failed to load hosts"}

); } return (
{/* Page Header */}

Hosts

Manage and monitor your connected hosts

{/* Stats Summary */}
{/* Hosts List */}
{selectedHosts.length > 0 && (
{selectedHosts.length} host {selectedHosts.length !== 1 ? "s" : ""} selected
)}
{/* Table Controls */}
{/* Search and Filter Bar */}
setSearchTerm(e.target.value)} className="pl-10 pr-4 py-2 w-full border border-secondary-300 dark:border-secondary-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400" />
{/* Advanced Filters */} {showFilters && (
)}
{!hosts || hosts.length === 0 ? (

No hosts registered yet

Click "Add Host" to manually register a new host and get API credentials

) : filteredAndSortedHosts.length === 0 ? (

No hosts match your current filters

Try adjusting your search terms or filters to see more results

) : (
{Object.entries(groupedHosts).map( ([groupName, groupHosts]) => (
{/* Group Header */} {groupBy !== "none" && (

{groupName} ({groupHosts.length})

)} {/* Table for this group */}
{visibleColumns.map((column) => ( ))} {groupHosts.map((host) => { const isInactive = (host.effectiveStatus || host.status) === "inactive"; const isSelected = selectedHosts.includes( host.id, ); let rowClasses = "hover:bg-secondary-50 dark:hover:bg-secondary-700"; if (isSelected) { rowClasses += " bg-primary-50 dark:bg-primary-600"; } else if (isInactive) { rowClasses += " bg-red-50 dark:bg-red-900/20"; } return ( {visibleColumns.map((column) => ( ))} ); })}
{column.id === "select" ? ( ) : column.id === "host" ? ( ) : column.id === "hostname" ? ( ) : column.id === "ip" ? ( ) : column.id === "group" ? ( ) : column.id === "os" ? ( ) : column.id === "os_version" ? ( ) : column.id === "agent_version" ? ( ) : column.id === "auto_update" ? (
{column.label}
) : column.id === "ws_status" ? (
{column.label}
) : column.id === "status" ? ( ) : column.id === "updates" ? ( ) : column.id === "security_updates" ? ( ) : column.id === "needs_reboot" ? ( ) : column.id === "last_update" ? ( ) : ( column.label )}
{renderCellContent(column, host)}
), )}
)}
{/* Modals */} setShowAddModal(false)} onSuccess={handleHostCreated} /> {/* Bulk Assign Modal */} {showBulkAssignModal && ( setShowBulkAssignModal(false)} onAssign={handleBulkAssign} isLoading={bulkUpdateGroupMutation.isPending} /> )} {/* Bulk Delete Modal */} {showBulkDeleteModal && ( setShowBulkDeleteModal(false)} onDelete={handleBulkDelete} isLoading={bulkDeleteMutation.isPending} /> )} {/* Column Settings Modal */} {showColumnSettings && ( setShowColumnSettings(false)} onToggleVisibility={toggleColumnVisibility} onReorder={reorderColumns} onReset={resetColumns} /> )}
); }; // Bulk Assign Modal Component const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading, }) => { const [selectedGroupIds, setSelectedGroupIds] = useState([]); // Fetch host groups for selection const { data: hostGroups } = useQuery({ queryKey: ["hostGroups"], queryFn: () => hostGroupsAPI.list().then((res) => res.data), }); const selectedHostNames = hosts .filter((host) => selectedHosts.includes(host.id)) .map((host) => host.friendly_name); const handleSubmit = (e) => { e.preventDefault(); onAssign(selectedGroupIds); }; const toggleGroup = (groupId) => { setSelectedGroupIds((prev) => { if (prev.includes(groupId)) { return prev.filter((id) => id !== groupId); } else { return [...prev, groupId]; } }); }; return (

Assign to Host Groups

Assigning {selectedHosts.length} host {selectedHosts.length !== 1 ? "s" : ""}:

{selectedHostNames.map((friendlyName) => (
• {friendlyName}
))}
Host Groups
{/* Host Group Options */} {hostGroups?.map((group) => ( ))}

Select one or more groups to assign these hosts to, or leave ungrouped.

); }; // Bulk Delete Modal Component const BulkDeleteModal = ({ selectedHosts, hosts, onClose, onDelete, isLoading, }) => { const selectedHostNames = hosts .filter((host) => selectedHosts.includes(host.id)) .map((host) => host.friendly_name || host.hostname || host.id); const handleSubmit = (e) => { e.preventDefault(); onDelete(); }; return (

Delete Hosts

Warning: This action cannot be undone

You are about to permanently delete {selectedHosts.length} host {selectedHosts.length !== 1 ? "s" : ""}. This will remove all host data, including package information, update history, and API credentials.

Hosts to be deleted:

{selectedHostNames.map((friendlyName) => (
• {friendlyName}
))}
); }; // Column Settings Modal Component const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset, }) => { const [draggedIndex, setDraggedIndex] = useState(null); const handleDragStart = (e, index) => { setDraggedIndex(index); e.dataTransfer.effectAllowed = "move"; }; const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const handleDrop = (e, dropIndex) => { e.preventDefault(); if (draggedIndex !== null && draggedIndex !== dropIndex) { onReorder(draggedIndex, dropIndex); } setDraggedIndex(null); }; return (
{/* Header */}

Column Settings

Drag to reorder columns or toggle visibility

{/* Scrollable content */}
{columnConfig.map((column, index) => ( ))}
{/* Footer */}
); }; export default Hosts;