Files
patchmon.net/frontend/src/pages/HostDetail.jsx
Muhammad Ibrahim c4d0d8bee8 Fixed repo count issue
Refactored code to remove duplicate backend api endpoints for counting
Improved connection persistence issues
Improved database connection pooling issues
Fixed redis connection efficiency
Changed version to 1.3.0
Fixed GO binary detection based on package manager rather than OS
2025-10-19 17:53:10 +01:00

2216 lines
76 KiB
JavaScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
CheckCircle2,
Clock,
Clock3,
Copy,
Cpu,
Database,
Eye,
EyeOff,
HardDrive,
Key,
MemoryStick,
Monitor,
Package,
RefreshCw,
Server,
Shield,
Terminal,
Trash2,
Wifi,
X,
} from "lucide-react";
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";
import { OSIcon } from "../utils/osIcons.jsx";
const HostDetail = () => {
const { hostId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showCredentialsModal, setShowCredentialsModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [activeTab, setActiveTab] = useState("host");
const [historyPage, setHistoryPage] = useState(0);
const [historyLimit] = useState(10);
const [notes, setNotes] = useState("");
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
const {
data: host,
isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["host", hostId, historyPage, historyLimit],
queryFn: () =>
dashboardAPI
.getHostDetail(hostId, {
limit: historyLimit,
offset: historyPage * historyLimit,
})
.then((res) => res.data),
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// WebSocket connection status using Server-Sent Events (SSE) for real-time push updates
const [wsStatus, setWsStatus] = useState(null);
useEffect(() => {
if (!host?.api_id) return;
const token = localStorage.getItem("token");
if (!token) return;
let eventSource = null;
let reconnectTimeout = null;
let isMounted = true;
const connect = () => {
if (!isMounted) return;
try {
// Create EventSource for SSE connection
eventSource = new EventSource(
`/api/v1/ws/status/${host.api_id}/stream?token=${encodeURIComponent(token)}`,
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setWsStatus(data);
} catch (_err) {
// Silently handle parse errors
}
};
eventSource.onerror = (_error) => {
console.log(`[SSE] Connection error for ${host.api_id}, retrying...`);
eventSource?.close();
// Automatic reconnection after 5 seconds
if (isMounted) {
reconnectTimeout = setTimeout(connect, 5000);
}
};
} catch (_err) {
// Silently handle connection errors
}
};
// Initial connection
connect();
// Cleanup on unmount or when api_id changes
return () => {
isMounted = false;
if (reconnectTimeout) clearTimeout(reconnectTimeout);
if (eventSource) {
eventSource.close();
}
};
}, [host?.api_id]);
// Fetch repository count for this host
const { data: repositories, isLoading: isLoadingRepos } = useQuery({
queryKey: ["host-repositories", hostId],
queryFn: () => repositoryAPI.getByHost(hostId).then((res) => res.data),
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
refetchOnWindowFocus: false, // Don't refetch when window regains focus
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);
};
// Auto-show credentials modal for new/pending hosts
useEffect(() => {
if (host && host.status === "pending") {
setShowCredentialsModal(true);
}
}, [host]);
// Sync notes state with host data
useEffect(() => {
if (host) {
setNotes(host.notes || "");
}
}, [host]);
const deleteHostMutation = useMutation({
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
onSuccess: () => {
queryClient.invalidateQueries(["hosts"]);
navigate("/hosts");
},
});
// Toggle agent auto-update mutation (updates PatchMon agent script, not system packages)
const toggleAutoUpdateMutation = useMutation({
mutationFn: (auto_update) =>
adminHostsAPI
.toggleAutoUpdate(hostId, auto_update)
.then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
},
});
const updateFriendlyNameMutation = useMutation({
mutationFn: (friendlyName) =>
adminHostsAPI
.updateFriendlyName(hostId, friendlyName)
.then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
},
});
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),
onSuccess: () => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
setNotesMessage({ text: "Notes saved successfully!", type: "success" });
// Clear message after 3 seconds
setTimeout(() => setNotesMessage({ text: "", type: "" }), 3000);
},
onError: (error) => {
setNotesMessage({
text: error.response?.data?.error || "Failed to save notes",
type: "error",
});
// Clear message after 5 seconds for errors
setTimeout(() => setNotesMessage({ text: "", type: "" }), 5000);
},
});
const handleDeleteHost = async () => {
if (
window.confirm(
`Are you sure you want to delete host "${host.friendly_name}"? This action cannot be undone.`,
)
) {
try {
await deleteHostMutation.mutateAsync(hostId);
} catch (error) {
console.error("Failed to delete host:", error);
alert("Failed to delete host");
}
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/hosts"
className="text-secondary-500 hover:text-secondary-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
</div>
</div>
<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 host
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host details"}
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
</div>
);
}
if (!host) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/hosts"
className="text-secondary-500 hover:text-secondary-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
</div>
</div>
<div className="card p-8 text-center">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
Host Not Found
</h3>
<p className="text-secondary-600 dark:text-secondary-300">
The requested host could not be found.
</p>
</div>
</div>
);
}
const getStatusColor = (isStale, needsUpdate) => {
if (isStale) return "text-danger-600";
if (needsUpdate) return "text-warning-600";
return "text-success-600";
};
const getStatusIcon = (isStale, needsUpdate) => {
if (isStale) return <AlertTriangle className="h-5 w-5" />;
if (needsUpdate) return <Clock className="h-5 w-5" />;
return <CheckCircle className="h-5 w-5" />;
};
const getStatusText = (isStale, needsUpdate) => {
if (isStale) return "Stale";
if (needsUpdate) return "Needs Updates";
return "Up to Date";
};
const isStale = Date.now() - new Date(host.last_update) > 24 * 60 * 60 * 1000;
return (
<div className="h-screen flex flex-col">
{/* Header */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-start gap-3">
<Link
to="/hosts"
className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 mt-1"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex flex-col gap-2">
{/* Title row with friendly name, badge, and status */}
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
{host.friendly_name}
</h1>
{wsStatus && (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 animate-pulse"
: "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 not connected"
}
>
{wsStatus.connected
? wsStatus.secure
? "WSS"
: "WS"
: "Offline"}
</span>
)}
<div
className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}
>
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div>
</div>
{/* Info row with uptime and last updated */}
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400">
{host.system_uptime && (
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Uptime:</span>
<span className="text-xs">{host.system_uptime}</span>
</div>
)}
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Last updated:</span>
<span className="text-xs">
{formatRelativeTime(host.last_update)}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2 text-sm"
>
<Key className="h-4 w-4" />
Deploy Agent
</button>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center justify-center p-2 text-sm"
title="Refresh host data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
<button
type="button"
onClick={() => setShowDeleteModal(true)}
className="btn-danger flex items-center justify-center p-2 text-sm"
title="Delete host"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Package Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<button
type="button"
onClick={() => navigate(`/packages?host=${hostId}`)}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="View all packages for this host"
>
<div className="flex items-center">
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Total Installed
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{host.stats.total_packages}
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => navigate(`/packages?host=${hostId}&filter=outdated`)}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="View outdated packages for this host"
>
<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">
Outdated Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{host.stats.outdated_packages}
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => navigate(`/packages?host=${hostId}&filter=security`)}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="View security packages for this host"
>
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Security Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{host.stats.security_updates}
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => navigate(`/repositories?host=${hostId}`)}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="View repositories for this host"
>
<div className="flex items-center">
<Database className="h-5 w-5 text-blue-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Repos
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{isLoadingRepos ? "..." : repositories?.length || 0}
</p>
</div>
</div>
</button>
</div>
{/* Main Content - Full Width */}
<div className="flex-1 overflow-hidden">
{/* Host Info, Hardware, Network, System Info in Tabs */}
<div className="card">
<div className="flex border-b border-secondary-200 dark:border-secondary-600">
<button
type="button"
onClick={() => handleTabChange("host")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "host"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Host Info
</button>
<button
type="button"
onClick={() => handleTabChange("network")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "network"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Network
</button>
<button
type="button"
onClick={() => handleTabChange("system")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "system"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
System
</button>
<button
type="button"
onClick={() => handleTabChange("history")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "history"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Package Reports
</button>
<button
type="button"
onClick={() => handleTabChange("queue")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "queue"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Agent Queue
</button>
<button
type="button"
onClick={() => handleTabChange("notes")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "notes"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Notes
</button>
</div>
<div className="p-4">
{/* Host Information */}
{activeTab === "host" && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Friendly Name
</p>
<InlineEdit
value={host.friendly_name}
onSave={(newName) =>
updateFriendlyNameMutation.mutate(newName)
}
placeholder="Enter friendly name..."
maxLength={100}
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 text-sm"
/>
</div>
{host.hostname && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
System Hostname
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.hostname}
</p>
</div>
)}
{host.machine_id && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Machine ID
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.machine_id}
</p>
</div>
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Host Groups
</p>
{/* 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>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Operating System
</p>
<div className="flex items-center gap-2">
<OSIcon osType={host.os_type} className="h-4 w-4" />
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.os_type} {host.os_version}
</p>
</div>
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Agent Version
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.agent_version || "Unknown"}
</p>
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Agent Auto-update
</p>
<button
type="button"
onClick={() =>
toggleAutoUpdateMutation.mutate(!host.auto_update)
}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.auto_update
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
host.auto_update ? "translate-x-5" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
)}
{/* Network Information */}
{activeTab === "network" &&
(host.ip ||
host.gateway_ip ||
host.dns_servers ||
host.network_interfaces) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{host.ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
IP Address
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.ip}
</p>
</div>
)}
{host.gateway_ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Gateway IP
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.gateway_ip}
</p>
</div>
)}
{host.dns_servers &&
Array.isArray(host.dns_servers) &&
host.dns_servers.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
DNS Servers
</p>
<div className="space-y-1">
{host.dns_servers.map((dns) => (
<p
key={dns}
className="font-medium text-secondary-900 dark:text-white font-mono text-sm"
>
{dns}
</p>
))}
</div>
</div>
)}
{host.network_interfaces &&
Array.isArray(host.network_interfaces) &&
host.network_interfaces.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Network Interfaces
</p>
<div className="space-y-1">
{host.network_interfaces.map((iface) => (
<p
key={iface.name}
className="font-medium text-secondary-900 dark:text-white text-sm"
>
{iface.name}
</p>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* System Information */}
{activeTab === "system" && (
<div className="space-y-6">
{/* Basic System Information */}
{(host.kernel_version ||
host.selinux_status ||
host.architecture) && (
<div>
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary-600 dark:text-primary-400" />
System Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.architecture && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Architecture
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.architecture}
</p>
</div>
)}
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Kernel Version
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.kernel_version}
</p>
</div>
)}
{/* Empty div to push SELinux status to the right */}
<div></div>
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
SELinux Status
</p>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinux_status === "enabled"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: host.selinux_status === "permissive"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
}`}
>
{host.selinux_status}
</span>
</div>
)}
</div>
</div>
)}
{/* Resource Information */}
{(host.system_uptime ||
host.cpu_model ||
host.cpu_cores ||
host.ram_installed ||
host.swap_size !== undefined ||
(host.load_average &&
Array.isArray(host.load_average) &&
host.load_average.length > 0 &&
host.load_average.some((load) => load != null)) ||
(host.disk_details &&
Array.isArray(host.disk_details) &&
host.disk_details.length > 0)) && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<Monitor className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Resource Information
</h4>
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* System Uptime */}
{host.system_uptime && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
System Uptime
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.system_uptime}
</p>
</div>
)}
{/* CPU Model */}
{host.cpu_model && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Model
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_model}
</p>
</div>
)}
{/* CPU Cores */}
{host.cpu_cores && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Cores
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_cores}
</p>
</div>
)}
{/* RAM Installed */}
{host.ram_installed && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
RAM Installed
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.ram_installed} GB
</p>
</div>
)}
{/* Swap Size */}
{host.swap_size !== undefined &&
host.swap_size !== null && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Swap Size
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.swap_size} GB
</p>
</div>
)}
{/* Load Average */}
{host.load_average &&
Array.isArray(host.load_average) &&
host.load_average.length > 0 &&
host.load_average.some((load) => load != null) && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Load Average
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.load_average
.filter((load) => load != null)
.map((load, index) => (
<span key={`load-${index}-${load}`}>
{typeof load === "number"
? load.toFixed(2)
: String(load)}
{index <
host.load_average.filter(
(load) => load != null,
).length -
1 && ", "}
</span>
))}
</p>
</div>
)}
</div>
{/* Disk Information */}
{host.disk_details &&
Array.isArray(host.disk_details) &&
host.disk_details.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h5>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.disk_details.map((disk, index) => (
<div
key={disk.name || `disk-${index}`}
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">
{disk.name || `Disk ${index + 1}`}
</span>
</div>
{disk.size && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Size: {disk.size}
</p>
)}
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Mount: {disk.mountpoint}
</p>
)}
{disk.usage &&
typeof disk.usage === "number" && (
<div className="mt-2">
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
<span>Usage</span>
<span>{disk.usage}%</span>
</div>
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
<div
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(Math.max(disk.usage, 0), 100)}%`,
}}
></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* No Data State */}
{!host.kernel_version &&
!host.selinux_status &&
!host.architecture &&
!host.system_uptime &&
!host.cpu_model &&
!host.cpu_cores &&
!host.ram_installed &&
host.swap_size === undefined &&
(!host.load_average ||
!Array.isArray(host.load_average) ||
host.load_average.length === 0 ||
!host.load_average.some((load) => load != null)) &&
(!host.disk_details ||
!Array.isArray(host.disk_details) ||
host.disk_details.length === 0) && (
<div className="text-center py-8">
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No system information available
</p>
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
System information will appear once the agent collects
data from this host
</p>
</div>
)}
</div>
)}
{activeTab === "network" &&
!(
host.ip ||
host.gateway_ip ||
host.dns_servers ||
host.network_interfaces
) && (
<div className="text-center py-8">
<Wifi className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No network information available
</p>
</div>
)}
{/* Update History */}
{activeTab === "history" && (
<div className="space-y-4">
{host.update_history?.length > 0 ? (
<>
<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>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Total Packages
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Outdated Packages
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Security
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Payload (KB)
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Exec Time (s)
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{host.update_history.map((update) => (
<tr
key={update.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<div
className={`w-1.5 h-1.5 rounded-full ${update.status === "success" ? "bg-success-500" : "bg-danger-500"}`}
/>
<span
className={`text-xs font-medium ${
update.status === "success"
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
>
{update.status === "success"
? "Success"
: "Failed"}
</span>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{formatDate(update.timestamp)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.total_packages || "-"}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.packages_count}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{update.security_count > 0 ? (
<div className="flex items-center gap-1">
<Shield className="h-3 w-3 text-danger-600" />
<span className="text-xs text-danger-600 font-medium">
{update.security_count}
</span>
</div>
) : (
<span className="text-xs text-secondary-500 dark:text-secondary-400">
-
</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.payload_size_kb
? `${update.payload_size_kb.toFixed(2)}`
: "-"}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.execution_time
? `${update.execution_time.toFixed(2)}`
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{host.pagination &&
host.pagination.total > historyLimit && (
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700">
<div className="flex items-center gap-2 text-sm text-secondary-600 dark:text-secondary-300">
<span>
Showing {historyPage * historyLimit + 1} to{" "}
{Math.min(
(historyPage + 1) * historyLimit,
host.pagination.total,
)}{" "}
of {host.pagination.total} entries
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setHistoryPage(0)}
disabled={historyPage === 0}
className="px-3 py-1 text-xs font-medium text-secondary-600 dark:text-secondary-300 hover:text-secondary-800 dark:hover:text-secondary-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
type="button"
onClick={() => setHistoryPage(historyPage - 1)}
disabled={historyPage === 0}
className="px-3 py-1 text-xs font-medium text-secondary-600 dark:text-secondary-300 hover:text-secondary-800 dark:hover:text-secondary-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1 text-xs font-medium text-secondary-900 dark:text-white">
Page {historyPage + 1} of{" "}
{Math.ceil(host.pagination.total / historyLimit)}
</span>
<button
type="button"
onClick={() => setHistoryPage(historyPage + 1)}
disabled={!host.pagination.hasMore}
className="px-3 py-1 text-xs font-medium text-secondary-600 dark:text-secondary-300 hover:text-secondary-800 dark:hover:text-secondary-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
type="button"
onClick={() =>
setHistoryPage(
Math.ceil(
host.pagination.total / historyLimit,
) - 1,
)
}
disabled={!host.pagination.hasMore}
className="px-3 py-1 text-xs font-medium text-secondary-600 dark:text-secondary-300 hover:text-secondary-800 dark:hover:text-secondary-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8">
<Calendar className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No update history available
</p>
</div>
)}
</div>
)}
{/* Notes */}
{activeTab === "notes" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Host Notes
</h3>
</div>
{/* Success/Error Message */}
{notesMessage.text && (
<div
className={`rounded-md p-4 ${
notesMessage.type === "success"
? "bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700"
: "bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700"
}`}
>
<div className="flex">
{notesMessage.type === "success" ? (
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
) : (
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
)}
<div className="ml-3">
<p
className={`text-sm font-medium ${
notesMessage.type === "success"
? "text-green-800 dark:text-green-200"
: "text-red-800 dark:text-red-200"
}`}
>
{notesMessage.text}
</p>
</div>
</div>
</div>
)}
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about this host... (e.g., purpose, special configurations, maintenance notes)"
className="w-full h-32 p-3 border border-secondary-200 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
maxLength={1000}
/>
<div className="flex justify-between items-center mt-3">
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Use this space to add important information about this
host for your team
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-400 dark:text-secondary-500">
{notes.length}/1000
</span>
<button
type="button"
onClick={() => {
updateNotesMutation.mutate({
hostId: host.id,
notes: notes,
});
}}
disabled={updateNotesMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:bg-primary-400 rounded-md transition-colors"
>
{updateNotesMutation.isPending
? "Saving..."
: "Save Notes"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Agent Queue */}
{activeTab === "queue" && <AgentQueueTab hostId={hostId} />}
</div>
</div>
</div>
{/* Credentials Modal */}
{showCredentialsModal && (
<CredentialsModal
host={host}
isOpen={showCredentialsModal}
onClose={() => setShowCredentialsModal(false)}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<DeleteConfirmationModal
host={host}
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDeleteHost}
isLoading={deleteHostMutation.isPending}
/>
)}
</div>
);
};
// Credentials Modal Component
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"],
queryFn: () => settingsAPI.getServerUrl().then((res) => res.data),
});
const serverUrl = serverUrlData?.server_url || "http://localhost:3001";
// Fetch settings for dynamic curl flags (local to modal)
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to get curl flags based on settings
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
// Helper function to build installation URL with optional force flag and architecture
const getInstallUrl = () => {
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
const params = new URLSearchParams();
if (forceInstall) params.append("force", "true");
params.append("arch", architecture);
return `${baseUrl}?${params.toString()}`;
};
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (!successful) {
throw new Error("Copy command failed");
}
} catch {
// If all else fails, show the text in a prompt
prompt("Copy this command:", text);
} finally {
document.body.removeChild(textArea);
}
} catch (err) {
console.error("Failed to copy to clipboard:", err);
// Show the text in a prompt as last resort
prompt("Copy this command:", text);
}
};
if (!isOpen || !host) 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-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Host Setup - {host.friendly_name}
</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>
{/* Tabs */}
<div className="border-b border-secondary-200 dark:border-secondary-600 mb-6">
<nav className="-mb-px flex space-x-8">
<button
type="button"
onClick={() => setActiveTab("quick-install")}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === "quick-install"
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
Quick Install
</button>
<button
type="button"
onClick={() => setActiveTab("credentials")}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === "credentials"
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
API Credentials
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === "quick-install" && (
<div className="space-y-4">
<div className="bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-primary-900 dark:text-primary-200 mb-2">
One-Line Installation
</h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to securely install
and configure the PatchMon agent:
</p>
{/* Force Install Toggle */}
<div className="mb-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceInstall}
onChange={(e) => setForceInstall(e.target.checked)}
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
/>
<span className="text-primary-800 dark:text-primary-200">
Force install (bypass broken packages)
</span>
</label>
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
Enable this if the target host has broken packages
(CloudPanel, WHM, etc.) that block apt-get operations
</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"
value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
)
}
className="btn-primary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Manual Installation
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
If you prefer to install manually, follow these steps:
</p>
<div className="space-y-3">
<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">
1. Create Configuration Directory
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo mkdir -p /etc/patchmon"
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 mkdir -p /etc/patchmon")
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</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">
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 ${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"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`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"
>
<Copy className="h-4 w-4" />
Copy
</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">
3. Configure Credentials
</h5>
<div className="flex items-center gap-2">
<input
type="text"
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"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</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">
4. Test Configuration
</h5>
<div className="flex items-center gap-2">
<input
type="text"
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"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
"sudo /usr/local/bin/patchmon-agent ping",
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</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">
5. Send Initial Data
</h5>
<div className="flex items-center gap-2">
<input
type="text"
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"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
"sudo /usr/local/bin/patchmon-agent report",
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</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">
6. Create Systemd Service File
</h5>
<div className="flex items-center gap-2">
<input
type="text"
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"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`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"
>
<Copy className="h-4 w-4" />
Copy
</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>
)}
{activeTab === "credentials" && (
<div className="space-y-6">
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
API Credentials
</h4>
<div className="space-y-4">
<div>
<label
htmlFor={apiIdInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
API ID
</label>
<div className="flex items-center gap-2">
<input
id={apiIdInputId}
type="text"
value={host.api_id}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() => copyToClipboard(host.api_id)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div>
<label
htmlFor={apiKeyInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyInputId}
type={showApiKey ? "text" : "password"}
value={host.api_key}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="btn-outline flex items-center gap-1"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={() => copyToClipboard(host.api_key)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
</div>
</div>
<div className="bg-warning-50 dark:bg-warning-900 border border-warning-200 dark:border-warning-700 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-warning-400 dark:text-warning-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-warning-800 dark:text-warning-200">
Security Notice
</h3>
<p className="text-sm text-warning-700 dark:text-warning-300 mt-1">
Keep these credentials secure. They provide full access to
this host's monitoring data.
</p>
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end pt-6">
<button type="button" onClick={onClose} className="btn-primary">
Close
</button>
</div>
</div>
</div>
);
};
// Delete Confirmation Modal Component
const DeleteConfirmationModal = ({
host,
isOpen,
onClose,
onConfirm,
isLoading,
}) => {
if (!isOpen || !host) 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 items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 dark:bg-danger-900 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600 dark:text-danger-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300">
Are you sure you want to delete the host{" "}
<span className="font-semibold">"{host.friendly_name}"</span>?
</p>
<div className="mt-3 p-3 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md">
<p className="text-sm text-danger-800 dark:text-danger-200">
<strong>Warning:</strong> This will permanently remove the host
and all its associated data, including package information and
update history.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="btn-danger"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete Host"}
</button>
</div>
</div>
</div>
);
};
// Agent Queue Tab Component
const AgentQueueTab = ({ hostId }) => {
const {
data: queueData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["host-queue", hostId],
queryFn: () => dashboardAPI.getHostQueue(hostId).then((res) => res.data),
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400">
Failed to load queue data
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-2 px-4 py-2 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
>
Retry
</button>
</div>
);
}
const { waiting, active, delayed, failed, jobHistory } = queueData.data;
const getStatusIcon = (status) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
case "active":
return <Clock3 className="h-4 w-4 text-blue-500" />;
default:
return <Clock className="h-4 w-4 text-gray-500" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case "completed":
return "text-green-600 dark:text-green-400";
case "failed":
return "text-red-600 dark:text-red-400";
case "active":
return "text-blue-600 dark:text-blue-400";
default:
return "text-gray-600 dark:text-gray-400";
}
};
const formatJobType = (type) => {
switch (type) {
case "settings_update":
return "Settings Update";
case "report_now":
return "Report Now";
case "update_agent":
return "Agent Update";
default:
return type;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Live Agent Queue Status
</h3>
<button
type="button"
onClick={() => refetch()}
className="btn-outline flex items-center gap-2"
title="Refresh queue data"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
{/* Queue Summary */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4">
<div className="flex items-center">
<Server className="h-5 w-5 text-blue-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Waiting
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{waiting}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<Clock3 className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Active
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{active}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<Clock className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Delayed
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{delayed}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Failed
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{failed}
</p>
</div>
</div>
</div>
</div>
{/* Job History */}
<div>
{jobHistory.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">
No job history found
</p>
</div>
) : (
<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>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Job ID
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Job Name
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Attempt
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Date/Time
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Error/Output
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{jobHistory.map((job) => (
<tr
key={job.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-4 py-2 whitespace-nowrap text-xs font-mono text-secondary-900 dark:text-white">
{job.job_id}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{formatJobType(job.job_name)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(job.status)}
<span
className={`text-xs font-medium ${getStatusColor(job.status)}`}
>
{job.status.charAt(0).toUpperCase() +
job.status.slice(1)}
</span>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{job.attempt_number}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{new Date(job.created_at).toLocaleString()}
</td>
<td className="px-4 py-2 text-xs">
{job.error_message ? (
<span className="text-red-600 dark:text-red-400">
{job.error_message}
</span>
) : job.output ? (
<span className="text-green-600 dark:text-green-400">
{JSON.stringify(job.output)}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
-
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default HostDetail;