mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-14 19:05:44 +00:00
Docker implementation
Profile fixes Hostgroup fixes TFA fixes
This commit is contained in:
@@ -28,6 +28,8 @@ const DockerContainerDetail = lazy(
|
||||
);
|
||||
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
|
||||
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
|
||||
const DockerVolumeDetail = lazy(() => import("./pages/docker/VolumeDetail"));
|
||||
const DockerNetworkDetail = lazy(() => import("./pages/docker/NetworkDetail"));
|
||||
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
|
||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
|
||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
|
||||
@@ -194,6 +196,26 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/docker/volumes/:id"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_reports">
|
||||
<Layout>
|
||||
<DockerVolumeDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/docker/networks/:id"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_reports">
|
||||
<Layout>
|
||||
<DockerNetworkDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
|
||||
@@ -91,10 +91,29 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
// Get or generate device ID for TFA remember-me
|
||||
let deviceId = localStorage.getItem("device_id");
|
||||
if (!deviceId) {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
deviceId = crypto.randomUUID();
|
||||
} else {
|
||||
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
},
|
||||
);
|
||||
}
|
||||
localStorage.setItem("device_id", deviceId);
|
||||
}
|
||||
|
||||
const response = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-ID": deviceId,
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
HardDrive,
|
||||
@@ -53,6 +54,8 @@ const HostDetail = () => {
|
||||
const [historyLimit] = useState(10);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
|
||||
const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" });
|
||||
const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" });
|
||||
|
||||
const {
|
||||
data: host,
|
||||
@@ -191,9 +194,50 @@ const HostDetail = () => {
|
||||
const forceAgentUpdateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
// Show success message with job ID
|
||||
if (data?.jobId) {
|
||||
setUpdateMessage({
|
||||
text: "Update queued successfully",
|
||||
jobId: data.jobId,
|
||||
});
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdateMessage({
|
||||
text: error.response?.data?.error || "Failed to queue update",
|
||||
jobId: "",
|
||||
});
|
||||
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch report mutation
|
||||
const fetchReportMutation = useMutation({
|
||||
mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
// Show success message with job ID
|
||||
if (data?.jobId) {
|
||||
setReportMessage({
|
||||
text: "Report fetch queued successfully",
|
||||
jobId: data.jobId,
|
||||
});
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setReportMessage({
|
||||
text: error.response?.data?.error || "Failed to fetch report",
|
||||
jobId: "",
|
||||
});
|
||||
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,20 +453,53 @@ const HostDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchReportMutation.mutate()}
|
||||
disabled={fetchReportMutation.isPending || !wsStatus?.connected}
|
||||
className="btn-outline flex items-center gap-2 text-sm"
|
||||
title={
|
||||
!wsStatus?.connected
|
||||
? "Agent is not connected"
|
||||
: "Fetch package data from agent"
|
||||
}
|
||||
>
|
||||
<Download
|
||||
className={`h-4 w-4 ${
|
||||
fetchReportMutation.isPending ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
Fetch Report
|
||||
</button>
|
||||
{reportMessage.text && (
|
||||
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
|
||||
{reportMessage.text}
|
||||
{reportMessage.jobId && (
|
||||
<span className="ml-1 font-mono text-secondary-500">
|
||||
(Job #{reportMessage.jobId})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCredentialsModal(true)}
|
||||
className="btn-outline flex items-center gap-2 text-sm"
|
||||
className={`btn-outline flex items-center text-sm ${
|
||||
host?.machine_id ? "justify-center p-2" : "gap-2"
|
||||
}`}
|
||||
title="View credentials"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
Deploy Agent
|
||||
{!host?.machine_id && <span>Deploy Agent</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center justify-center p-2 text-sm"
|
||||
title="Refresh host data"
|
||||
title="Refresh dashboard"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
@@ -716,12 +793,20 @@ const HostDetail = () => {
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||
Force Update
|
||||
Force Agent Version Upgrade
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => forceAgentUpdateMutation.mutate()}
|
||||
disabled={forceAgentUpdateMutation.isPending}
|
||||
disabled={
|
||||
forceAgentUpdateMutation.isPending ||
|
||||
!wsStatus?.connected
|
||||
}
|
||||
title={
|
||||
!wsStatus?.connected
|
||||
? "Agent is not connected"
|
||||
: "Force agent to update now"
|
||||
}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -733,8 +818,20 @@ const HostDetail = () => {
|
||||
/>
|
||||
{forceAgentUpdateMutation.isPending
|
||||
? "Updating..."
|
||||
: "Update Now"}
|
||||
: wsStatus?.connected
|
||||
? "Update Now"
|
||||
: "Offline"}
|
||||
</button>
|
||||
{updateMessage.text && (
|
||||
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
|
||||
{updateMessage.text}
|
||||
{updateMessage.jobId && (
|
||||
<span className="ml-1 font-mono text-secondary-500">
|
||||
(Job #{updateMessage.jobId})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,9 +470,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
// Fetch hosts for this group
|
||||
const { data: hostsData } = useQuery({
|
||||
queryKey: ["hostGroupHosts", group?.id],
|
||||
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
|
||||
enabled: !!group && group._count?.hosts > 0,
|
||||
});
|
||||
|
||||
const hosts = hostsData || [];
|
||||
|
||||
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="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
@@ -494,12 +503,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<p className="text-sm text-warning-800 mb-2">
|
||||
<strong>Warning:</strong> This group contains{" "}
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
||||
these hosts before deleting the group.
|
||||
</p>
|
||||
{hosts.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-warning-900 mb-1">
|
||||
Hosts in this group:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="text-xs text-warning-900 flex items-center gap-1"
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
{host.friendly_name || host.hostname}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,12 +531,11 @@ const Hosts = () => {
|
||||
"with new data:",
|
||||
data.host,
|
||||
);
|
||||
// Ensure hostGroupId is set correctly
|
||||
// Host already has host_group_memberships from backend
|
||||
const updatedHost = {
|
||||
...data.host,
|
||||
hostGroupId: data.host.host_groups?.id || null,
|
||||
};
|
||||
console.log("Updated host with hostGroupId:", updatedHost);
|
||||
console.log("Updated host in cache:", updatedHost);
|
||||
return updatedHost;
|
||||
}
|
||||
return host;
|
||||
@@ -654,11 +653,15 @@ const Hosts = () => {
|
||||
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Group filter
|
||||
// Group filter - handle multiple groups per host
|
||||
const memberships = host.host_group_memberships || [];
|
||||
const matchesGroup =
|
||||
groupFilter === "all" ||
|
||||
(groupFilter === "ungrouped" && !host.host_groups) ||
|
||||
(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
|
||||
(groupFilter === "ungrouped" && memberships.length === 0) ||
|
||||
(groupFilter !== "ungrouped" &&
|
||||
memberships.some(
|
||||
(membership) => membership.host_groups?.id === groupFilter,
|
||||
));
|
||||
|
||||
// Status filter
|
||||
const matchesStatus =
|
||||
@@ -711,10 +714,30 @@ const Hosts = () => {
|
||||
aValue = a.ip?.toLowerCase() || "zzz_no_ip";
|
||||
bValue = b.ip?.toLowerCase() || "zzz_no_ip";
|
||||
break;
|
||||
case "group":
|
||||
aValue = a.host_groups?.name || "zzz_ungrouped";
|
||||
bValue = b.host_groups?.name || "zzz_ungrouped";
|
||||
case "group": {
|
||||
// Handle multiple groups per host - use first group alphabetically for sorting
|
||||
const aGroups = a.host_group_memberships || [];
|
||||
const bGroups = b.host_group_memberships || [];
|
||||
if (aGroups.length === 0) {
|
||||
aValue = "zzz_ungrouped";
|
||||
} else {
|
||||
const aGroupNames = aGroups
|
||||
.map((m) => m.host_groups?.name || "")
|
||||
.filter((name) => name)
|
||||
.sort();
|
||||
aValue = aGroupNames[0] || "zzz_ungrouped";
|
||||
}
|
||||
if (bGroups.length === 0) {
|
||||
bValue = "zzz_ungrouped";
|
||||
} else {
|
||||
const bGroupNames = bGroups
|
||||
.map((m) => m.host_groups?.name || "")
|
||||
.filter((name) => name)
|
||||
.sort();
|
||||
bValue = bGroupNames[0] || "zzz_ungrouped";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "os":
|
||||
aValue = a.os_type?.toLowerCase() || "zzz_unknown";
|
||||
bValue = b.os_type?.toLowerCase() || "zzz_unknown";
|
||||
@@ -787,27 +810,46 @@ const Hosts = () => {
|
||||
|
||||
const groups = {};
|
||||
filteredAndSortedHosts.forEach((host) => {
|
||||
let groupKey;
|
||||
switch (groupBy) {
|
||||
case "group":
|
||||
groupKey = host.host_groups?.name || "Ungrouped";
|
||||
break;
|
||||
case "status":
|
||||
groupKey =
|
||||
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
|
||||
(host.effectiveStatus || host.status).slice(1);
|
||||
break;
|
||||
case "os":
|
||||
groupKey = host.os_type || "Unknown";
|
||||
break;
|
||||
default:
|
||||
groupKey = "All Hosts";
|
||||
}
|
||||
if (groupBy === "group") {
|
||||
// Handle multiple groups per host
|
||||
const memberships = host.host_group_memberships || [];
|
||||
if (memberships.length === 0) {
|
||||
// Host has no groups, add to "Ungrouped"
|
||||
if (!groups.Ungrouped) {
|
||||
groups.Ungrouped = [];
|
||||
}
|
||||
groups.Ungrouped.push(host);
|
||||
} else {
|
||||
// Host has one or more groups, add to each group
|
||||
memberships.forEach((membership) => {
|
||||
const groupName = membership.host_groups?.name || "Unknown";
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(host);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Other grouping types (status, os, etc.)
|
||||
let groupKey;
|
||||
switch (groupBy) {
|
||||
case "status":
|
||||
groupKey =
|
||||
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
|
||||
(host.effectiveStatus || host.status).slice(1);
|
||||
break;
|
||||
case "os":
|
||||
groupKey = host.os_type || "Unknown";
|
||||
break;
|
||||
default:
|
||||
groupKey = "All Hosts";
|
||||
}
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [];
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [];
|
||||
}
|
||||
groups[groupKey].push(host);
|
||||
}
|
||||
groups[groupKey].push(host);
|
||||
});
|
||||
|
||||
return groups;
|
||||
@@ -1394,14 +1436,6 @@ const Hosts = () => {
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Hide Stale
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Host
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -407,7 +407,12 @@ const Login = () => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[name]:
|
||||
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
|
||||
type === "checkbox"
|
||||
? checked
|
||||
: value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "")
|
||||
.slice(0, 6),
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
@@ -872,7 +877,8 @@ const Login = () => {
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
Enter the code from your authenticator app, or use a backup
|
||||
code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -891,11 +897,15 @@ const Login = () => {
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest uppercase"
|
||||
placeholder="Enter code"
|
||||
maxLength="6"
|
||||
pattern="[A-Z0-9]{6}"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Enter a 6-digit TOTP code or a 6-character backup code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
@@ -955,12 +965,6 @@ const Login = () => {
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -557,9 +557,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
// Fetch hosts for this group
|
||||
const { data: hostsData } = useQuery({
|
||||
queryKey: ["hostGroupHosts", group?.id],
|
||||
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
|
||||
enabled: !!group && group._count?.hosts > 0,
|
||||
});
|
||||
|
||||
const hosts = hostsData || [];
|
||||
|
||||
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="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
@@ -581,12 +590,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<p className="text-sm text-warning-800 mb-2">
|
||||
<strong>Warning:</strong> This group contains{" "}
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
||||
these hosts before deleting the group.
|
||||
</p>
|
||||
{hosts.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-warning-900 mb-1">
|
||||
Hosts in this group:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="text-xs text-warning-900 flex items-center gap-1"
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
{host.friendly_name || host.hostname}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -539,7 +539,7 @@ const Packages = () => {
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Packages
|
||||
Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalPackagesCount}
|
||||
@@ -553,7 +553,7 @@ const Packages = () => {
|
||||
<Package className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Installations
|
||||
Installations
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalInstallationsCount}
|
||||
@@ -562,47 +562,72 @@ const Packages = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUpdateStatusFilter("needs-updates");
|
||||
setCategoryFilter("all");
|
||||
setHostFilter("all");
|
||||
setSearchTerm("");
|
||||
}}
|
||||
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="Click to filter packages that need updates"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Outdated Packages
|
||||
Outdated Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{outdatedPackagesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Hosts Pending Updates
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{uniquePackageHostsCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUpdateStatusFilter("security-updates");
|
||||
setCategoryFilter("all");
|
||||
setHostFilter("all");
|
||||
setSearchTerm("");
|
||||
}}
|
||||
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="Click to filter packages with security updates"
|
||||
>
|
||||
<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 Across All Hosts
|
||||
Security Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{securityUpdatesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/hosts?filter=needsUpdates")}
|
||||
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="Click to view hosts that need updates"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Outdated Hosts
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{uniquePackageHostsCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Packages List */}
|
||||
|
||||
@@ -564,6 +564,7 @@ const Profile = () => {
|
||||
// TFA Tab Component
|
||||
const TfaTab = () => {
|
||||
const verificationTokenId = useId();
|
||||
const disablePasswordId = useId();
|
||||
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
|
||||
const [verificationToken, setVerificationToken] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
483
frontend/src/pages/docker/NetworkDetail.jsx
Normal file
483
frontend/src/pages/docker/NetworkDetail.jsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Container,
|
||||
Globe,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Tag,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import api, { formatRelativeTime } from "../../utils/api";
|
||||
|
||||
const NetworkDetail = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["docker", "network", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/docker/networks/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const network = data?.network;
|
||||
const host = data?.host;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !network) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Network not found
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
The network you're looking for doesn't exist or has been
|
||||
removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/docker"
|
||||
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Docker
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => {
|
||||
return value ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
{trueLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
{falseLabel}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/docker"
|
||||
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Docker
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<Network className="h-8 w-8 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{network.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Network ID: {network.network_id.substring(0, 12)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Network className="h-5 w-5 text-blue-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Driver
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{network.driver}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Globe className="h-5 w-5 text-purple-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Scope
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{network.scope}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Container className="h-5 w-5 text-green-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Containers
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{network.container_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Last Checked
|
||||
</p>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(network.last_checked)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Information Card */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
||||
Network Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Network ID
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
|
||||
{network.network_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Name
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{network.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Driver
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{network.driver}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Scope
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
{network.scope}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Containers Attached
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{network.container_count || 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
IPv6 Enabled
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<BooleanBadge value={network.ipv6_enabled} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Internal
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<BooleanBadge value={network.internal} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Attachable
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<BooleanBadge value={network.attachable} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Ingress
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<BooleanBadge value={network.ingress} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Config Only
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<BooleanBadge value={network.config_only} />
|
||||
</dd>
|
||||
</div>
|
||||
{network.created_at && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Created
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(network.created_at)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Last Checked
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(network.last_checked)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IPAM Configuration */}
|
||||
{network.ipam && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
||||
IPAM Configuration
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
IP Address Management settings
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
{network.ipam.driver && (
|
||||
<div className="mb-4">
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1">
|
||||
Driver
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{network.ipam.driver}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{network.ipam.config && network.ipam.config.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3">
|
||||
Subnet Configuration
|
||||
</dt>
|
||||
<div className="space-y-4">
|
||||
{network.ipam.config.map((config, index) => (
|
||||
<div
|
||||
key={config.subnet || `config-${index}`}
|
||||
className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4"
|
||||
>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
{config.subnet && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Subnet
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{config.subnet}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{config.gateway && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Gateway
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{config.gateway}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{config.ip_range && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
||||
IP Range
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{config.ip_range}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{config.aux_addresses &&
|
||||
Object.keys(config.aux_addresses).length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Auxiliary Addresses
|
||||
</dt>
|
||||
<dd className="space-y-1">
|
||||
{Object.entries(config.aux_addresses).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center text-sm"
|
||||
>
|
||||
<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white font-mono">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{network.ipam.options &&
|
||||
Object.keys(network.ipam.options).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
IPAM Options
|
||||
</dt>
|
||||
<dd className="space-y-1">
|
||||
{Object.entries(network.ipam.options).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Host Information */}
|
||||
{host && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
Host Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Hostname
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
<Link
|
||||
to={`/hosts/${host.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{host.hostname}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Operating System
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{host.os_name} {host.os_version}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{network.labels && Object.keys(network.labels).length > 0 && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
||||
<Tag className="h-5 w-5 mr-2" />
|
||||
Labels
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(network.labels).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkDetail;
|
||||
359
frontend/src/pages/docker/VolumeDetail.jsx
Normal file
359
frontend/src/pages/docker/VolumeDetail.jsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import api, { formatRelativeTime } from "../../utils/api";
|
||||
|
||||
const VolumeDetail = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["docker", "volume", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/docker/volumes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const volume = data?.volume;
|
||||
const host = data?.host;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !volume) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Volume not found
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
The volume you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/docker"
|
||||
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Docker
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === null || bytes === undefined) return "N/A";
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/docker"
|
||||
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Docker
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<HardDrive className="h-8 w-8 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{volume.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Volume ID: {volume.volume_id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<HardDrive className="h-5 w-5 text-blue-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Driver
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{volume.driver}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Database className="h-5 w-5 text-purple-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Size</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{formatBytes(volume.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-green-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Containers
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{volume.ref_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Last Checked
|
||||
</p>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(volume.last_checked)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume Information Card */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
||||
Volume Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Volume ID
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{volume.volume_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Name
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{volume.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Driver
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{volume.driver}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Scope
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
{volume.scope}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Size
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{formatBytes(volume.size_bytes)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Containers Using
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{volume.ref_count || 0}
|
||||
</dd>
|
||||
</div>
|
||||
{volume.mountpoint && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Mount Point
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
|
||||
{volume.mountpoint}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{volume.renderer && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Renderer
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{volume.renderer}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Created
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(volume.created_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Last Checked
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{formatRelativeTime(volume.last_checked)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host Information */}
|
||||
{host && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
Host Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Hostname
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
<Link
|
||||
to={`/hosts/${host.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{host.hostname}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Operating System
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
||||
{host.os_name} {host.os_version}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{volume.labels && Object.keys(volume.labels).length > 0 && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
||||
<Tag className="h-5 w-5 mr-2" />
|
||||
Labels
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(volume.labels).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{volume.options && Object.keys(volume.options).length > 0 && (
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
||||
Volume Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(volume.options).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VolumeDetail;
|
||||
@@ -746,239 +746,126 @@ const Integrations = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Docker Container Monitoring
|
||||
Docker Inventory Collection
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Monitor Docker containers and images for available updates
|
||||
Docker monitoring is now built into the PatchMon Go agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Instructions */}
|
||||
{/* Info Message */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
|
||||
Agent Installation
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
|
||||
Automatic Docker Discovery
|
||||
</h4>
|
||||
<p className="text-sm text-primary-800 dark:text-primary-300 mb-3">
|
||||
The PatchMon Go agent automatically discovers Docker
|
||||
when it's available on your host and collects
|
||||
comprehensive inventory information:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2">
|
||||
<li>
|
||||
<strong>Containers</strong> - Running and stopped
|
||||
containers with status, images, ports, and labels
|
||||
</li>
|
||||
<li>
|
||||
<strong>Images</strong> - All Docker images with
|
||||
repository, tags, sizes, and sources
|
||||
</li>
|
||||
<li>
|
||||
<strong>Volumes</strong> - Named and anonymous volumes
|
||||
with drivers, mountpoints, and usage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Networks</strong> - Docker networks with
|
||||
drivers, IPAM configuration, and connected containers
|
||||
</li>
|
||||
<li>
|
||||
<strong>Real-time Updates</strong> - Container status
|
||||
changes are pushed instantly via WebSocket
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
How It Works
|
||||
</h4>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<li>
|
||||
Make sure you have the PatchMon credentials file set up on
|
||||
your host (
|
||||
<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
|
||||
/etc/patchmon/credentials
|
||||
</code>
|
||||
)
|
||||
Install the PatchMon Go agent on your host (see the Hosts
|
||||
page for installation instructions)
|
||||
</li>
|
||||
<li>
|
||||
SSH into your Docker host where you want to monitor
|
||||
containers
|
||||
The agent automatically detects if Docker is installed and
|
||||
running on the host
|
||||
</li>
|
||||
<li>Run the installation command below</li>
|
||||
<li>
|
||||
The agent will automatically collect Docker container and
|
||||
image information every 5 minutes
|
||||
During each collection cycle, the agent gathers Docker
|
||||
inventory data and sends it to the PatchMon server
|
||||
</li>
|
||||
<li>
|
||||
View your complete Docker inventory (containers, images,
|
||||
volumes, networks) in the{" "}
|
||||
<a
|
||||
href="/docker"
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline"
|
||||
>
|
||||
Docker page
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Container status changes are pushed to the server in
|
||||
real-time via WebSocket connection
|
||||
</li>
|
||||
<li>View your Docker inventory in the Docker page</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Installation Command */}
|
||||
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
|
||||
Quick Installation (One-Line Command)
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Download and install the Docker agent:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`,
|
||||
"docker-install",
|
||||
)
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
|
||||
>
|
||||
{copy_success["docker-install"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||
💡 This will download the agent, make it executable, and
|
||||
set up a cron job to run every 5 minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Installation Steps */}
|
||||
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
|
||||
Manual Installation Steps
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<strong>Step 1:</strong> Download the agent
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`,
|
||||
"docker-download",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
{copy_success["docker-download"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<strong>Step 2:</strong> Make it executable
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="chmod +x /usr/local/bin/patchmon-docker-agent.sh"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
"chmod +x /usr/local/bin/patchmon-docker-agent.sh",
|
||||
"docker-chmod",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
{copy_success["docker-chmod"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<strong>Step 3:</strong> Test the agent
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="/usr/local/bin/patchmon-docker-agent.sh collect"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
"/usr/local/bin/patchmon-docker-agent.sh collect",
|
||||
"docker-test",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
{copy_success["docker-test"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<strong>Step 4:</strong> Set up automatic collection
|
||||
(every 5 minutes)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -'
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -',
|
||||
"docker-cron",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
{copy_success["docker-cron"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
{/* No Configuration Required */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-semibold mb-2">Prerequisites:</p>
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-green-800 dark:text-green-200">
|
||||
<p className="font-semibold mb-1">
|
||||
No Additional Configuration Required
|
||||
</p>
|
||||
<p>
|
||||
Once the Go agent is installed and Docker is running on
|
||||
your host, Docker inventory collection happens
|
||||
automatically. No separate Docker agent or cron jobs
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-semibold mb-2">Requirements:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>PatchMon Go agent must be installed and running</li>
|
||||
<li>Docker daemon must be installed and running</li>
|
||||
<li>
|
||||
Docker must be installed and running on the host
|
||||
</li>
|
||||
<li>
|
||||
PatchMon credentials file must exist at{" "}
|
||||
<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs">
|
||||
/etc/patchmon/credentials
|
||||
Agent must have access to the Docker socket (
|
||||
<code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs">
|
||||
/var/run/docker.sock
|
||||
</code>
|
||||
)
|
||||
</li>
|
||||
<li>
|
||||
The host must have network access to your PatchMon
|
||||
server
|
||||
Typically requires running the agent as root or with
|
||||
Docker group permissions
|
||||
</li>
|
||||
<li>The agent must run as root (or with sudo)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -215,8 +215,8 @@ const SettingsHostGroups = () => {
|
||||
title={`View hosts in ${group.name}`}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}
|
||||
{group._count?.hosts || 0} host
|
||||
{group._count?.hosts !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
@@ -539,9 +539,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
// Fetch hosts for this group
|
||||
const { data: hostsData } = useQuery({
|
||||
queryKey: ["hostGroupHosts", group?.id],
|
||||
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
|
||||
enabled: !!group && group._count?.hosts > 0,
|
||||
});
|
||||
|
||||
const hosts = hostsData || [];
|
||||
|
||||
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="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
@@ -561,14 +570,32 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
Are you sure you want to delete the host group{" "}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
{group._count?.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
<strong>Note:</strong> This group contains {group._count?.hosts}{" "}
|
||||
host
|
||||
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
|
||||
to "No group" after deletion.
|
||||
{group._count?.hosts !== 1 ? "s" : ""}. These hosts will be
|
||||
moved to "No group" after deletion.
|
||||
</p>
|
||||
{hosts.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-blue-900 mb-1">
|
||||
Hosts in this group:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-blue-100 rounded p-2">
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="text-xs text-blue-900 flex items-center gap-1"
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
{host.friendly_name || host.hostname}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,30 @@ api.interceptors.request.use(
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add device ID for TFA remember-me functionality
|
||||
// This uniquely identifies the browser profile (normal vs incognito)
|
||||
let deviceId = localStorage.getItem("device_id");
|
||||
if (!deviceId) {
|
||||
// Generate a unique device ID and store it
|
||||
// Use crypto.randomUUID() if available, otherwise generate a UUID v4 manually
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
deviceId = crypto.randomUUID();
|
||||
} else {
|
||||
// Fallback: Generate UUID v4 manually
|
||||
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
},
|
||||
);
|
||||
}
|
||||
localStorage.setItem("device_id", deviceId);
|
||||
}
|
||||
config.headers["X-Device-ID"] = deviceId;
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -96,6 +120,7 @@ export const adminHostsAPI = {
|
||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
|
||||
fetchReport: (hostId) => api.post(`/hosts/${hostId}/fetch-report`),
|
||||
updateFriendlyName: (hostId, friendlyName) =>
|
||||
api.patch(`/hosts/${hostId}/friendly-name`, {
|
||||
friendly_name: friendlyName,
|
||||
|
||||
171
frontend/src/utils/docker.js
Normal file
171
frontend/src/utils/docker.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Docker-related utility functions for the frontend
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a registry link for a Docker image based on its repository and source
|
||||
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
|
||||
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
|
||||
* @returns {string|null} - The URL to the registry page, or null if unknown
|
||||
*/
|
||||
export function generateRegistryLink(repository, source) {
|
||||
if (!repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the domain and path from the repository
|
||||
const parts = repository.split("/");
|
||||
let domain = "";
|
||||
let path = "";
|
||||
|
||||
// Check if repository has a domain (contains a dot)
|
||||
if (parts[0].includes(".") || parts[0].includes(":")) {
|
||||
domain = parts[0];
|
||||
path = parts.slice(1).join("/");
|
||||
} else {
|
||||
// No domain means Docker Hub
|
||||
domain = "docker.io";
|
||||
path = repository;
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case "docker-hub":
|
||||
case "docker.io": {
|
||||
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
|
||||
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
|
||||
// or explicitly prefixed with "library/"
|
||||
if (path.startsWith("library/")) {
|
||||
const cleanPath = path.replace("library/", "");
|
||||
return `https://hub.docker.com/_/${cleanPath}`;
|
||||
}
|
||||
// Check if it's an official image (single part, no slash after removing library/)
|
||||
if (!path.includes("/")) {
|
||||
return `https://hub.docker.com/_/${path}`;
|
||||
}
|
||||
// Regular user/org image
|
||||
return `https://hub.docker.com/r/${path}`;
|
||||
}
|
||||
|
||||
case "github":
|
||||
case "ghcr.io": {
|
||||
// GitHub Container Registry
|
||||
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
|
||||
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
|
||||
if (domain === "ghcr.io" && path) {
|
||||
const pathParts = path.split("/");
|
||||
if (pathParts.length === 2) {
|
||||
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
|
||||
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
|
||||
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
|
||||
const owner = pathParts[0];
|
||||
const packageName = pathParts[1];
|
||||
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
|
||||
} else if (pathParts.length >= 3) {
|
||||
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
|
||||
const owner = pathParts[0];
|
||||
const repo = pathParts[1];
|
||||
const packageName = pathParts.slice(2).join("/");
|
||||
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
|
||||
}
|
||||
}
|
||||
// Legacy GitHub Packages
|
||||
if (domain === "docker.pkg.github.com" && path) {
|
||||
const pathParts = path.split("/");
|
||||
if (pathParts.length >= 1) {
|
||||
return `https://github.com/${pathParts[0]}/packages`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "gitlab":
|
||||
case "registry.gitlab.com": {
|
||||
// GitLab Container Registry
|
||||
if (path) {
|
||||
return `https://gitlab.com/${path}/container_registry`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "google":
|
||||
case "gcr.io": {
|
||||
// Google Container Registry
|
||||
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
|
||||
return `https://console.cloud.google.com/gcr/images/${path}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "quay":
|
||||
case "quay.io": {
|
||||
// Quay.io
|
||||
if (path) {
|
||||
return `https://quay.io/repository/${path}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "redhat":
|
||||
case "registry.access.redhat.com": {
|
||||
// Red Hat
|
||||
if (path) {
|
||||
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "azure":
|
||||
case "azurecr.io": {
|
||||
// Azure Container Registry
|
||||
if (domain.includes("azurecr.io")) {
|
||||
const registryName = domain.split(".")[0];
|
||||
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "aws":
|
||||
case "amazonaws.com": {
|
||||
// AWS ECR
|
||||
if (domain.includes("amazonaws.com")) {
|
||||
const domainParts = domain.split(".");
|
||||
const region = domainParts[3]; // Extract region
|
||||
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "private":
|
||||
// For private registries, try to construct a basic URL
|
||||
if (domain) {
|
||||
return `https://${domain}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly display name for a registry source
|
||||
* @param {string} source - The source identifier
|
||||
* @returns {string} - Human-readable source name
|
||||
*/
|
||||
export function getSourceDisplayName(source) {
|
||||
const sourceNames = {
|
||||
"docker-hub": "Docker Hub",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
google: "Google",
|
||||
quay: "Quay.io",
|
||||
redhat: "Red Hat",
|
||||
azure: "Azure",
|
||||
aws: "AWS ECR",
|
||||
private: "Private Registry",
|
||||
local: "Local",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
return sourceNames[source] || source;
|
||||
}
|
||||
Reference in New Issue
Block a user