Files
patchmon.net/frontend/src/pages/Hosts.jsx
Muhammad Ibrahim 5457a1e9bc Docker implementation
Profile fixes
Hostgroup fixes
TFA fixes
2025-10-31 15:24:53 +00:00

2126 lines
67 KiB
JavaScript

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,
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Add New Host
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor={friendlyNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Friendly Name *
</label>
<input
type="text"
id={friendlyNameId}
required
value={formData.friendly_name}
onChange={(e) =>
setFormData({ ...formData, friendly_name: e.target.value })
}
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
placeholder="server.example.com"
/>
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
System information (OS, IP, architecture) will be automatically
detected when the agent connects.
</p>
</div>
<div>
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Host Groups
</span>
<div className="space-y-2 max-h-48 overflow-y-auto">
{/* Host Group Options */}
{hostGroups?.map((group) => (
<label
key={group.id}
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"
}`}
>
<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-sm font-medium text-secondary-700 dark:text-secondary-200">
{group.name}
</div>
</div>
</label>
))}
</div>
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
Optional: Select one or more groups to assign this host to for
better organization.
</p>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
>
{isSubmitting ? "Creating..." : "Create Host"}
</button>
</div>
</form>
</div>
</div>
);
};
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: "updates", label: "Updates", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 },
];
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 {
// 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
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, or offline hosts
const filter = searchParams.get("filter");
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);
// 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 "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 <ArrowUpDown className="h-4 w-4" />;
return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
// Column management functions
const 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: "updates", label: "Updates", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 },
];
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 (
<button
type="button"
onClick={() => handleSelectHost(host.id)}
className="flex items-center gap-2 hover:text-secondary-700"
>
{selectedHosts.includes(host.id) ? (
<CheckSquare className="h-4 w-4 text-primary-600" />
) : (
<Square className="h-4 w-4 text-secondary-400" />
)}
</button>
);
case "host":
return (
<InlineEdit
value={host.friendly_name}
onSave={(newName) =>
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 (
<div className="text-sm text-secondary-900 dark:text-white font-mono">
{host.hostname || "N/A"}
</div>
);
case "ip":
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.ip || "N/A"}
</div>
);
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 (
<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"
/>
);
}
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>{getOSDisplayName(host.os_type)}</span>
</div>
);
case "os_version":
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.os_version || "N/A"}
</div>
);
case "agent_version":
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.agent_version || "N/A"}
</div>
);
case "auto_update":
return (
<InlineToggle
value={host.auto_update}
onSave={(autoUpdate) =>
toggleAutoUpdateMutation.mutate({
hostId: host.id,
autoUpdate: autoUpdate,
})
}
trueLabel="Yes"
falseLabel="No"
/>
);
case "ws_status": {
const wsStatus = wsStatusMap[host.api_id];
if (!wsStatus) {
return (
<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-1 rounded-full text-xs font-medium ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
title={
wsStatus.connected
? `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>
);
}
case "status":
return (
<div className="text-sm text-secondary-900 dark:text-white">
{(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
(host.effectiveStatus || host.status).slice(1)}
</div>
);
case "updates":
return (
<button
type="button"
onClick={() =>
navigate(`/packages?host=${host.id}&filter=outdated`)
}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
title="View outdated packages for this host"
>
{host.updatesCount || 0}
</button>
);
case "last_update":
return (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{formatRelativeTime(host.last_update)}
</div>
);
case "notes":
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs">
{host.notes ? (
<div className="truncate" title={host.notes}>
{host.notes}
</div>
) : (
<span className="text-secondary-400 dark:text-secondary-500 italic">
No notes
</span>
)}
</div>
);
case "actions":
return (
<Link
to={`/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
>
View
<ExternalLink className="h-3 w-3" />
</Link>
);
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);
// Use the upToDate URL filter
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "upToDate");
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
const handleNeedsUpdatesClick = () => {
// Filter to show hosts needing updates (regardless of status)
setStatusFilter("all");
setShowFilters(true);
// We'll use the existing needsUpdates URL filter logic
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "needsUpdates");
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
const handleConnectionStatusClick = () => {
// Filter to show offline hosts (not connected via WebSocket)
setStatusFilter("all");
setShowFilters(true);
// Use a new URL filter for connection status
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "offline");
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading hosts
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load hosts"}
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
);
}
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Hosts
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your connected hosts
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center justify-center p-2"
title="Refresh hosts data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Host
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={handleTotalHostsClick}
>
<div className="flex items-center">
<Server className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Total Hosts
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.length || 0}
</p>
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={handleUpToDateClick}
>
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Up to Date
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.filter((h) => !h.isStale && h.updatesCount === 0)
.length || 0}
</p>
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={handleNeedsUpdatesClick}
>
<div className="flex items-center">
<Clock className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Needs Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.filter((h) => h.updatesCount > 0).length || 0}
</p>
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={handleConnectionStatusClick}
>
<div className="flex items-center">
<Wifi className="h-5 w-5 text-primary-600 mr-2" />
<div className="flex-1">
<p className="text-sm text-secondary-500 dark:text-white mb-1">
Connection Status
</p>
{(() => {
const connectedCount =
hosts?.filter(
(h) => wsStatusMap[h.api_id]?.connected === true,
).length || 0;
const offlineCount =
hosts?.filter(
(h) => wsStatusMap[h.api_id]?.connected !== true,
).length || 0;
return (
<div className="flex gap-4">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{connectedCount}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Connected
</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{offlineCount}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Offline
</span>
</div>
</div>
);
})()}
</div>
</div>
</button>
</div>
{/* Hosts List */}
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
<div className="flex items-center justify-end mb-4">
{selectedHosts.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm text-secondary-600">
{selectedHosts.length} host
{selectedHosts.length !== 1 ? "s" : ""} selected
</span>
<button
type="button"
onClick={() => setShowBulkAssignModal(true)}
className="btn-outline flex items-center gap-2"
>
<Users className="h-4 w-4" />
Assign to Group
</button>
<button
type="button"
onClick={() => setShowBulkDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
<button
type="button"
onClick={() => setSelectedHosts([])}
className="text-sm text-secondary-500 hover:text-secondary-700"
>
Clear Selection
</button>
</div>
)}
</div>
{/* Table Controls */}
<div className="mb-4 space-y-4">
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search hosts, IP addresses, or OS..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`btn-outline flex items-center gap-2 ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
>
<Filter className="h-4 w-4" />
Filters
</button>
<button
type="button"
onClick={() => setShowColumnSettings(true)}
className="btn-outline flex items-center gap-2"
>
<Columns className="h-4 w-4" />
Columns
</button>
<div className="relative">
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
>
<option value="none">No Grouping</option>
<option value="group">By Group</option>
<option value="status">By Status</option>
<option value="os">By OS</option>
</select>
<ChevronDown className="absolute right-1 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
</div>
<button
type="button"
onClick={() => setHideStale(!hideStale)}
className={`btn-outline flex items-center gap-2 ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
>
<AlertTriangle className="h-4 w-4" />
Hide Stale
</button>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg border dark:border-secondary-600">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label
htmlFor={hostGroupFilterId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Host Group
</label>
<select
id={hostGroupFilterId}
value={groupFilter}
onChange={(e) => setGroupFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Groups</option>
<option value="ungrouped">Ungrouped</option>
{hostGroups?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div>
<label
htmlFor={statusFilterId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Status
</label>
<select
id={statusFilterId}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Inactive</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label
htmlFor={osFilterId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Operating System
</label>
<select
id={osFilterId}
value={osFilter}
onChange={(e) => setOsFilter(e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All OS</option>
{uniqueOsTypes.map((osType) => (
<option key={osType} value={osType.toLowerCase()}>
{osType}
</option>
))}
</select>
</div>
<div className="flex items-end">
<button
type="button"
onClick={() => {
setSearchTerm("");
setGroupFilter("all");
setStatusFilter("all");
setOsFilter("all");
setGroupBy("none");
setHideStale(false);
}}
className="btn-outline w-full"
>
Clear Filters
</button>
</div>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
{!hosts || hosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts registered yet</p>
<p className="text-sm text-secondary-400 mt-2">
Click "Add Host" to manually register a new host and get API
credentials
</p>
</div>
) : filteredAndSortedHosts.length === 0 ? (
<div className="text-center py-8">
<Search className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">
No hosts match your current filters
</p>
<p className="text-sm text-secondary-400 mt-2">
Try adjusting your search terms or filters to see more results
</p>
</div>
) : (
<div className="h-full overflow-auto">
<div className="space-y-6">
{Object.entries(groupedHosts).map(
([groupName, groupHosts]) => (
<div key={groupName} className="space-y-3">
{/* Group Header */}
{groupBy !== "none" && (
<div className="flex items-center justify-between bg-secondary-100 dark:bg-secondary-700 px-4 py-2 rounded-lg">
<h3 className="text-sm font-medium text-secondary-900 dark:text-white">
{groupName} ({groupHosts.length})
</h3>
</div>
)}
{/* Table for this group */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
{visibleColumns.map((column) => (
<th
key={column.id}
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
{column.id === "select" ? (
<button
type="button"
onClick={handleSelectAll}
className="flex items-center gap-2 hover:text-secondary-700"
>
{selectedHosts.length ===
groupHosts.length ? (
<CheckSquare className="h-4 w-4" />
) : (
<Square className="h-4 w-4" />
)}
</button>
) : column.id === "host" ? (
<button
type="button"
onClick={() =>
handleSort("friendlyName")
}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("friendlyName")}
</button>
) : column.id === "hostname" ? (
<button
type="button"
onClick={() => handleSort("hostname")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("hostname")}
</button>
) : column.id === "ip" ? (
<button
type="button"
onClick={() => handleSort("ip")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("ip")}
</button>
) : column.id === "group" ? (
<button
type="button"
onClick={() => handleSort("group")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("group")}
</button>
) : column.id === "os" ? (
<button
type="button"
onClick={() => handleSort("os")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("os")}
</button>
) : column.id === "os_version" ? (
<button
type="button"
onClick={() => handleSort("os_version")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("os_version")}
</button>
) : column.id === "agent_version" ? (
<button
type="button"
onClick={() =>
handleSort("agent_version")
}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("agent_version")}
</button>
) : column.id === "auto_update" ? (
<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"
onClick={() => handleSort("status")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("status")}
</button>
) : column.id === "updates" ? (
<button
type="button"
onClick={() => handleSort("updates")}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("updates")}
</button>
) : column.id === "last_update" ? (
<button
type="button"
onClick={() =>
handleSort("last_update")
}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("last_update")}
</button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{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 (
<tr key={host.id} className={rowClasses}>
{visibleColumns.map((column) => (
<td
key={column.id}
className="px-4 py-2 whitespace-nowrap text-center"
>
{renderCellContent(column, host)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
),
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
<AddHostModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={handleHostCreated}
/>
{/* Bulk Assign Modal */}
{showBulkAssignModal && (
<BulkAssignModal
selectedHosts={selectedHosts}
hosts={hosts}
onClose={() => setShowBulkAssignModal(false)}
onAssign={handleBulkAssign}
isLoading={bulkUpdateGroupMutation.isPending}
/>
)}
{/* Bulk Delete Modal */}
{showBulkDeleteModal && (
<BulkDeleteModal
selectedHosts={selectedHosts}
hosts={hosts}
onClose={() => setShowBulkDeleteModal(false)}
onDelete={handleBulkDelete}
isLoading={bulkDeleteMutation.isPending}
/>
)}
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
columnConfig={columnConfig}
onClose={() => setShowColumnSettings(false)}
onToggleVisibility={toggleColumnVisibility}
onReorder={reorderColumns}
onReset={resetColumns}
/>
)}
</div>
);
};
// 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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Assign to Host Groups
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-300 dark:hover:text-secondary-100"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="mb-4">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
Assigning {selectedHosts.length} host
{selectedHosts.length !== 1 ? "s" : ""}:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
{selectedHostNames.map((friendlyName) => (
<div
key={friendlyName}
className="text-sm text-secondary-700 dark:text-secondary-300"
>
{friendlyName}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Host Groups
</span>
<div className="space-y-2 max-h-48 overflow-y-auto">
{/* Host Group Options */}
{hostGroups?.map((group) => (
<label
key={group.id}
className={`flex items-center gap-3 p-3 border-2 rounded-lg transition-all duration-200 cursor-pointer ${
selectedGroupIds.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"
}`}
>
<input
type="checkbox"
checked={selectedGroupIds.includes(group.id)}
onChange={() => toggleGroup(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-sm font-medium text-secondary-700 dark:text-secondary-200">
{group.name}
</div>
</div>
</label>
))}
</div>
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
Select one or more groups to assign these hosts to, or leave
ungrouped.
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Assigning..." : "Assign to Groups"}
</button>
</div>
</form>
</div>
</div>
);
};
// 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 (
<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="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Hosts
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
disabled={isLoading}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-6 py-4">
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="h-5 w-5 text-danger-600" />
<h4 className="text-sm font-medium text-danger-800 dark:text-danger-200">
Warning: This action cannot be undone
</h4>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
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.
</p>
</div>
<div className="mb-4">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
Hosts to be deleted:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
{selectedHostNames.map((friendlyName) => (
<div
key={friendlyName}
className="text-sm text-secondary-700 dark:text-secondary-300"
>
{friendlyName}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-danger" disabled={isLoading}>
{isLoading
? "Deleting..."
: `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? "s" : ""}`}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
// Column Settings Modal Component
const ColumnSettingsModal = ({
columnConfig,
onClose,
onToggleVisibility,
onReorder,
onReset,
}) => {
const [draggedIndex, setDraggedIndex] = useState(null);
const handleDragStart = (e, index) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = (e, dropIndex) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex);
}
setDraggedIndex(null);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 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
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
Drag to reorder columns or toggle visibility
</p>
</div>
{/* 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}
type="button"
draggable
tabIndex={0}
aria-label={`Drag to reorder ${column.label} column`}
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
// Focus handling for keyboard users
}
}}
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
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-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 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"
}`}
>
{column.visible ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeOffIcon className="h-4 w-4" />
)}
</button>
</button>
))}
</div>
</div>
{/* 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>
<button type="button" onClick={onClose} className="btn-primary">
Done
</button>
</div>
</div>
</div>
</div>
);
};
export default Hosts;