Files
patchmon.net/frontend/src/pages/HostDetail.jsx
Muhammad Ibrahim 46eb797ac3 I should really commit more often instead of sending over one massive commit
Blame my ADHD brain
Sorry
- Now we have the server working properly in automation using BullMQ and Redis
- It also presents an API endpoint that is used to accept connections for websockets by agents (WS or WSS)
- Updated the docker-compose.yml and its documentation
2025-10-17 22:10:55 +01:00

2173 lines
74 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 {
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"]);
},
});
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>
<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;