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

1810 lines
70 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Container,
ExternalLink,
HardDrive,
Network,
Package,
RefreshCw,
Search,
Server,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import api from "../utils/api";
import { generateRegistryLink, getSourceDisplayName } from "../utils/docker";
const Docker = () => {
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("containers");
const [sortField, setSortField] = useState("status");
const [sortDirection, setSortDirection] = useState("asc");
const [statusFilter, setStatusFilter] = useState("all");
const [sourceFilter, setSourceFilter] = useState("all");
const [updatesFilter, setUpdatesFilter] = useState("all");
const [driverFilter, setDriverFilter] = useState("all");
const [deleteContainerModal, setDeleteContainerModal] = useState(null);
const [deleteImageModal, setDeleteImageModal] = useState(null);
const [deleteVolumeModal, setDeleteVolumeModal] = useState(null);
const [deleteNetworkModal, setDeleteNetworkModal] = useState(null);
// Fetch Docker dashboard data
const { data: dashboard, isLoading: dashboardLoading } = useQuery({
queryKey: ["docker", "dashboard"],
queryFn: async () => {
const response = await api.get("/docker/dashboard");
return response.data;
},
refetchInterval: 30000, // Refresh every 30 seconds
});
// Fetch containers
const {
data: containersData,
isLoading: containersLoading,
refetch: refetchContainers,
} = useQuery({
queryKey: ["docker", "containers", statusFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (statusFilter !== "all") params.set("status", statusFilter);
params.set("limit", "1000");
const response = await api.get(`/docker/containers?${params}`);
return response.data;
},
enabled: activeTab === "containers",
});
// Fetch images
const {
data: imagesData,
isLoading: imagesLoading,
refetch: refetchImages,
} = useQuery({
queryKey: ["docker", "images", sourceFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (sourceFilter !== "all") params.set("source", sourceFilter);
params.set("limit", "1000");
const response = await api.get(`/docker/images?${params}`);
return response.data;
},
enabled: activeTab === "images",
});
// Fetch hosts
const { data: hostsData, isLoading: hostsLoading } = useQuery({
queryKey: ["docker", "hosts"],
queryFn: async () => {
const response = await api.get("/docker/hosts?limit=1000");
return response.data;
},
enabled: activeTab === "hosts",
});
// Fetch volumes
const {
data: volumesData,
isLoading: volumesLoading,
refetch: refetchVolumes,
} = useQuery({
queryKey: ["docker", "volumes", driverFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (driverFilter !== "all") params.set("driver", driverFilter);
params.set("limit", "1000");
const response = await api.get(`/docker/volumes?${params}`);
return response.data;
},
enabled: activeTab === "volumes",
});
// Fetch networks
const {
data: networksData,
isLoading: networksLoading,
refetch: refetchNetworks,
} = useQuery({
queryKey: ["docker", "networks", driverFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (driverFilter !== "all") params.set("driver", driverFilter);
params.set("limit", "1000");
const response = await api.get(`/docker/networks?${params}`);
return response.data;
},
enabled: activeTab === "networks",
});
// Delete container mutation
const deleteContainerMutation = useMutation({
mutationFn: async (containerId) => {
const response = await api.delete(`/docker/containers/${containerId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "containers"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteContainerModal(null);
},
onError: (error) => {
alert(
`Failed to delete container: ${error.response?.data?.error || error.message}`,
);
},
});
// Delete image mutation
const deleteImageMutation = useMutation({
mutationFn: async (imageId) => {
const response = await api.delete(`/docker/images/${imageId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "images"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteImageModal(null);
},
onError: (error) => {
alert(
`Failed to delete image: ${error.response?.data?.error || error.message}`,
);
},
});
// Delete volume mutation
const deleteVolumeMutation = useMutation({
mutationFn: async (volumeId) => {
const response = await api.delete(`/docker/volumes/${volumeId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "volumes"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteVolumeModal(null);
},
onError: (error) => {
alert(
`Failed to delete volume: ${error.response?.data?.error || error.message}`,
);
},
});
// Delete network mutation
const deleteNetworkMutation = useMutation({
mutationFn: async (networkId) => {
const response = await api.delete(`/docker/networks/${networkId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "networks"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteNetworkModal(null);
},
onError: (error) => {
alert(
`Failed to delete network: ${error.response?.data?.error || error.message}`,
);
},
});
// Filter and sort containers
const filteredContainers = useMemo(() => {
if (!containersData?.containers) return [];
let filtered = containersData.containers;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(c) =>
c.name.toLowerCase().includes(term) ||
c.image_name.toLowerCase().includes(term) ||
c.host?.friendly_name?.toLowerCase().includes(term),
);
}
filtered.sort((a, b) => {
let aValue, bValue;
if (sortField === "name") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortField === "image") {
aValue = a.image_name?.toLowerCase() || "";
bValue = b.image_name?.toLowerCase() || "";
} else if (sortField === "status") {
// Custom status priority: running first, then others alphabetically
const statusPriority = {
running: 1,
created: 2,
restarting: 3,
paused: 4,
exited: 5,
dead: 6,
};
const aPriority = statusPriority[a.status] || 999;
const bPriority = statusPriority[b.status] || 999;
if (sortDirection === "asc") {
if (aPriority !== bPriority) return aPriority - bPriority;
// Secondary sort by name within same status
return (a.name?.toLowerCase() || "").localeCompare(
b.name?.toLowerCase() || "",
);
} else {
if (aPriority !== bPriority) return bPriority - aPriority;
// Secondary sort by name within same status
return (b.name?.toLowerCase() || "").localeCompare(
a.name?.toLowerCase() || "",
);
}
} else if (sortField === "host") {
aValue = a.host?.friendly_name?.toLowerCase() || "";
bValue = b.host?.friendly_name?.toLowerCase() || "";
}
if (sortField !== "status") {
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
}
return 0;
});
return filtered;
}, [containersData, searchTerm, sortField, sortDirection]);
// Filter and sort images
const filteredImages = useMemo(() => {
if (!imagesData?.images) return [];
let filtered = imagesData.images;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(img) =>
img.repository.toLowerCase().includes(term) ||
img.tag.toLowerCase().includes(term),
);
}
// Filter by updates status
if (updatesFilter !== "all") {
if (updatesFilter === "available") {
filtered = filtered.filter((img) => img.hasUpdates === true);
} else if (updatesFilter === "none") {
filtered = filtered.filter((img) => !img.hasUpdates);
}
}
filtered.sort((a, b) => {
let aValue, bValue;
if (sortField === "repository") {
aValue = a.repository?.toLowerCase() || "";
bValue = b.repository?.toLowerCase() || "";
} else if (sortField === "tag") {
aValue = a.tag || "";
bValue = b.tag || "";
} else if (sortField === "containers") {
aValue = a._count?.docker_containers || 0;
bValue = b._count?.docker_containers || 0;
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [imagesData, searchTerm, sortField, sortDirection, updatesFilter]);
// Filter and sort hosts
const filteredHosts = useMemo(() => {
if (!hostsData?.hosts) return [];
let filtered = hostsData.hosts;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(h) =>
h.friendly_name?.toLowerCase().includes(term) ||
h.hostname?.toLowerCase().includes(term),
);
}
filtered.sort((a, b) => {
let aValue, bValue;
if (sortField === "name") {
aValue = a.friendly_name?.toLowerCase() || "";
bValue = b.friendly_name?.toLowerCase() || "";
} else if (sortField === "containers") {
aValue = a.dockerStats?.totalContainers || 0;
bValue = b.dockerStats?.totalContainers || 0;
} else if (sortField === "images") {
aValue = a.dockerStats?.totalImages || 0;
bValue = b.dockerStats?.totalImages || 0;
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [hostsData, searchTerm, sortField, sortDirection]);
// Filter and sort volumes
const filteredVolumes = useMemo(() => {
if (!volumesData?.volumes) return [];
let filtered = volumesData.volumes;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(v) =>
v.name.toLowerCase().includes(term) ||
v.hosts?.friendly_name?.toLowerCase().includes(term),
);
}
filtered.sort((a, b) => {
let aValue, bValue;
if (sortField === "name") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortField === "driver") {
aValue = a.driver?.toLowerCase() || "";
bValue = b.driver?.toLowerCase() || "";
} else if (sortField === "size") {
aValue = a.size_bytes ? BigInt(a.size_bytes) : BigInt(0);
bValue = b.size_bytes ? BigInt(b.size_bytes) : BigInt(0);
} else if (sortField === "ref_count") {
aValue = a.ref_count || 0;
bValue = b.ref_count || 0;
} else if (sortField === "host") {
aValue = a.hosts?.friendly_name?.toLowerCase() || "";
bValue = b.hosts?.friendly_name?.toLowerCase() || "";
}
if (sortField === "size") {
// BigInt comparison
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
} else if (sortField === "ref_count") {
// Number comparison
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
} else {
// String comparison
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
}
return 0;
});
return filtered;
}, [volumesData, searchTerm, sortField, sortDirection]);
// Filter and sort networks
const filteredNetworks = useMemo(() => {
if (!networksData?.networks) return [];
let filtered = networksData.networks;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(n) =>
n.name.toLowerCase().includes(term) ||
n.hosts?.friendly_name?.toLowerCase().includes(term),
);
}
filtered.sort((a, b) => {
let aValue, bValue;
if (sortField === "name") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortField === "driver") {
aValue = a.driver?.toLowerCase() || "";
bValue = b.driver?.toLowerCase() || "";
} else if (sortField === "containers") {
aValue = a.container_count || 0;
bValue = b.container_count || 0;
} else if (sortField === "host") {
aValue = a.hosts?.friendly_name?.toLowerCase() || "";
bValue = b.hosts?.friendly_name?.toLowerCase() || "";
}
if (sortField === "containers") {
// Number comparison
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
} else {
// String comparison
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
}
return 0;
});
return filtered;
}, [networksData, searchTerm, sortField, sortDirection]);
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
const getStatusBadge = (status) => {
const statusClasses = {
running:
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
exited: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
paused:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
restarting:
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
statusClasses[status] ||
"bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"
}`}
>
{status}
</span>
);
};
const getSourceBadge = (source, repository) => {
// Generate registry link if possible
const registryLink = repository
? generateRegistryLink(repository, source)
: null;
// Get display name
const displayName = getSourceDisplayName(source);
// Color schemes for different sources
const colorSchemes = {
"docker-hub":
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800",
github:
"bg-secondary-100 text-secondary-900 dark:bg-secondary-700 dark:text-white hover:bg-secondary-200 dark:hover:bg-secondary-600",
gitlab:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 hover:bg-orange-200 dark:hover:bg-orange-800",
google:
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800",
quay: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200 hover:bg-teal-200 dark:hover:bg-teal-800",
redhat:
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800",
azure:
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800",
aws: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-800",
private:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800",
local:
"bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200 hover:bg-secondary-200 dark:hover:bg-secondary-600",
unknown:
"bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200 hover:bg-secondary-200 dark:hover:bg-secondary-600",
};
const colorScheme = colorSchemes[source] || colorSchemes.unknown;
if (registryLink) {
// Return as clickable link
return (
<a
href={registryLink}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${colorScheme}`}
title={`View on ${displayName}`}
>
{displayName}
<ExternalLink className="h-3 w-3" />
</a>
);
}
// Return as non-clickable badge
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorScheme.split(" hover:")[0]}`}
>
{displayName}
</span>
);
};
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Docker Inventory
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Monitor containers, images, and updates across your infrastructure
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
// Trigger refresh based on active tab
if (activeTab === "containers") refetchContainers();
else if (activeTab === "images") refetchImages();
else if (activeTab === "volumes") refetchVolumes();
else if (activeTab === "networks") refetchNetworks();
else window.location.reload();
}}
className="btn-outline flex items-center justify-center p-2"
title="Refresh data"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Hosts with Docker
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{dashboardLoading ? (
<span className="animate-pulse">-</span>
) : (
dashboard?.stats?.totalHostsWithDocker || 0
)}
</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">
Running Containers
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{dashboardLoading ? (
<span className="animate-pulse">-</span>
) : (
<>
{dashboard?.stats?.runningContainers || 0}
<span className="ml-2 text-sm text-secondary-500 dark:text-secondary-400 font-normal">
/ {dashboard?.stats?.totalContainers || 0} total
</span>
</>
)}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Package 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">
Total Images
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{dashboardLoading ? (
<span className="animate-pulse">-</span>
) : (
dashboard?.stats?.totalImages || 0
)}
</p>
</div>
</div>
</div>
<button
type="button"
onClick={() => {
setActiveTab("images");
setUpdatesFilter("available");
setSourceFilter("all"); // Reset source filter
setSearchTerm(""); // Clear search
setSortField("repository");
setSortDirection("asc");
}}
className="card p-4 hover:shadow-lg transition-shadow cursor-pointer text-left w-full disabled:opacity-50 disabled:cursor-not-allowed"
disabled={dashboardLoading || !dashboard?.stats?.availableUpdates}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Updates Available
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{dashboardLoading ? (
<span className="animate-pulse">-</span>
) : (
dashboard?.stats?.availableUpdates || 0
)}
</p>
</div>
</div>
</button>
</div>
{/* Docker List */}
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
{[
{ id: "containers", label: "Containers", icon: Container },
{ id: "images", label: "Images", icon: Package },
{ id: "volumes", label: "Volumes", icon: HardDrive },
{ id: "networks", label: "Networks", icon: Network },
{ id: "hosts", label: "Hosts", icon: Server },
].map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveTab(tab.id);
setSearchTerm("");
setUpdatesFilter("all"); // Reset updates filter when switching tabs
setSortField(
tab.id === "containers"
? "status"
: tab.id === "images"
? "repository"
: tab.id === "volumes"
? "name"
: tab.id === "networks"
? "name"
: "name",
);
setSortDirection("asc");
}}
className={`${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300"
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center`}
>
<Icon className="h-4 w-4 mr-2" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Filters and Search */}
<div className="p-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-secondary-400" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder={`Search ${activeTab}...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<X className="h-5 w-5 text-secondary-400 hover:text-secondary-600" />
</button>
)}
</div>
</div>
{activeTab === "containers" && (
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="block w-full sm:w-48 pl-3 pr-10 py-2 text-base border-secondary-300 dark:border-secondary-600 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="running">Running</option>
<option value="exited">Exited</option>
<option value="paused">Paused</option>
<option value="restarting">Restarting</option>
</select>
)}
{activeTab === "images" && (
<>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="block w-full sm:w-48 pl-3 pr-10 py-2 text-base border-secondary-300 dark:border-secondary-600 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="all">All Sources</option>
<option value="docker-hub">Docker Hub</option>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="google">Google</option>
<option value="quay">Quay.io</option>
<option value="redhat">Red Hat</option>
<option value="azure">Azure</option>
<option value="aws">AWS ECR</option>
<option value="private">Private</option>
<option value="local">Local</option>
</select>
<select
value={updatesFilter}
onChange={(e) => setUpdatesFilter(e.target.value)}
className="block w-full sm:w-48 pl-3 pr-10 py-2 text-base border-secondary-300 dark:border-secondary-600 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="all">All Updates Status</option>
<option value="available">Has Updates</option>
<option value="none">Up to Date</option>
</select>
</>
)}
{(activeTab === "volumes" || activeTab === "networks") && (
<select
value={driverFilter}
onChange={(e) => setDriverFilter(e.target.value)}
className="block w-full sm:w-48 pl-3 pr-10 py-2 text-base border-secondary-300 dark:border-secondary-600 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="all">All Drivers</option>
<option value="local">Local</option>
<option value="bridge">Bridge</option>
<option value="host">Host</option>
<option value="overlay">Overlay</option>
<option value="macvlan">Macvlan</option>
</select>
)}
</div>
</div>
{/* Tab Content */}
<div className="p-4 flex-1 overflow-auto">
{/* Containers Tab */}
{activeTab === "containers" && (
<div className="overflow-x-auto">
{containersLoading ? (
<div className="text-center py-8">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-secondary-400" />
<p className="mt-2 text-sm text-secondary-500">
Loading containers...
</p>
</div>
) : filteredContainers.length === 0 ? (
<div className="text-center py-8">
<Container className="h-12 w-12 mx-auto text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No containers found
</h3>
<p className="mt-1 text-sm text-secondary-500">
{searchTerm
? "Try adjusting your search filters"
: "No Docker containers detected on any hosts"}
</p>
</div>
) : (
<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">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Container Name
{getSortIcon("name")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("image")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Image
{getSortIcon("image")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("status")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Status
{getSortIcon("status")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("host")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host
{getSortIcon("host")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredContainers.map((container) => (
<tr
key={container.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/containers/${container.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{container.name}
</Link>
</div>
</td>
<td className="px-4 py-2">
<div className="text-sm text-secondary-900 dark:text-white">
{container.image_name}:{container.image_tag}
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
{getStatusBadge(container.status)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<Link
to={`/hosts/${container.host_id}`}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{container.host?.friendly_name ||
container.host?.hostname ||
"Unknown"}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-3">
<Link
to={`/docker/containers/${container.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteContainerModal(container)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
title="Delete container from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Images Tab */}
{activeTab === "images" && (
<div className="overflow-x-auto">
{imagesLoading ? (
<div className="text-center py-8">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-secondary-400" />
<p className="mt-2 text-sm text-secondary-500">
Loading images...
</p>
</div>
) : filteredImages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 mx-auto text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No images found
</h3>
<p className="mt-1 text-sm text-secondary-500">
{searchTerm
? "Try adjusting your search filters"
: "No Docker images detected"}
</p>
</div>
) : (
<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">
<button
type="button"
onClick={() => handleSort("repository")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Repository
{getSortIcon("repository")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("tag")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Tag
{getSortIcon("tag")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Source
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Containers
{getSortIcon("containers")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Updates
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredImages.map((image) => (
<tr
key={image.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/images/${image.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{image.repository}
</Link>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<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">
{image.tag}
</span>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
{getSourceBadge(image.source, image.repository)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{image._count?.docker_containers || 0}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
{image.hasUpdates ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<AlertTriangle className="h-3 w-3 mr-1" />
Available
</span>
) : (
<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">
Up to date
</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-3">
<Link
to={`/docker/images/${image.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteImageModal(image)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
title="Delete image from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Hosts Tab */}
{activeTab === "hosts" && (
<div className="overflow-x-auto">
{hostsLoading ? (
<div className="text-center py-8">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-secondary-400" />
<p className="mt-2 text-sm text-secondary-500">
Loading hosts...
</p>
</div>
) : filteredHosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 mx-auto text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No hosts found
</h3>
<p className="mt-1 text-sm text-secondary-500">
{searchTerm
? "Try adjusting your search filters"
: "No hosts with Docker detected"}
</p>
</div>
) : (
<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">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host Name
{getSortIcon("name")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Containers
{getSortIcon("containers")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Running
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("images")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Images
{getSortIcon("images")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredHosts.map((host) => (
<tr
key={host.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/hosts/${host.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{host.friendly_name || host.hostname}
</Link>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{host.dockerStats?.totalContainers || 0}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium">
{host.dockerStats?.runningContainers || 0}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{host.dockerStats?.totalImages || 0}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<Link
to={`/docker/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Volumes Tab */}
{activeTab === "volumes" && (
<div className="overflow-x-auto">
{volumesLoading ? (
<div className="text-center py-8">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-secondary-400" />
<p className="mt-2 text-sm text-secondary-500">
Loading volumes...
</p>
</div>
) : filteredVolumes.length === 0 ? (
<div className="text-center py-8">
<HardDrive className="h-12 w-12 mx-auto text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No volumes found
</h3>
<p className="mt-1 text-sm text-secondary-500">
{searchTerm
? "Try adjusting your search filters"
: "No Docker volumes detected"}
</p>
</div>
) : (
<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">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Volume Name
{getSortIcon("name")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("driver")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Driver
{getSortIcon("driver")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("size")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Size
{getSortIcon("size")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("ref_count")}
className="flex items-center gap-2 hover:text-secondary-700"
>
In Use
{getSortIcon("ref_count")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("host")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host
{getSortIcon("host")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredVolumes.map((volume) => (
<tr
key={volume.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/volumes/${volume.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{volume.name}
</Link>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<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>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{volume.size_bytes
? `${(Number(volume.size_bytes) / 1024 / 1024 / 1024).toFixed(2)} GB`
: "-"}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{volume.ref_count > 0 ? (
<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">
{volume.ref_count} container
{volume.ref_count !== 1 ? "s" : ""}
</span>
) : (
<span className="text-secondary-400 dark:text-secondary-500">
Unused
</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<Link
to={`/hosts/${volume.host_id}`}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{volume.hosts?.friendly_name ||
volume.hosts?.hostname ||
"Unknown"}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-2">
<Link
to={`/docker/volumes/${volume.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteVolumeModal(volume)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Networks Tab */}
{activeTab === "networks" && (
<div className="overflow-x-auto">
{networksLoading ? (
<div className="text-center py-8">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-secondary-400" />
<p className="mt-2 text-sm text-secondary-500">
Loading networks...
</p>
</div>
) : filteredNetworks.length === 0 ? (
<div className="text-center py-8">
<Network className="h-12 w-12 mx-auto text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No networks found
</h3>
<p className="mt-1 text-sm text-secondary-500">
{searchTerm
? "Try adjusting your search filters"
: "No Docker networks detected"}
</p>
</div>
) : (
<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">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Network Name
{getSortIcon("name")}
</button>
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("driver")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Driver
{getSortIcon("driver")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Scope
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Containers
{getSortIcon("containers")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Flags
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("host")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host
{getSortIcon("host")}
</button>
</th>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredNetworks.map((network) => (
<tr
key={network.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Network className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/networks/${network.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{network.name}
</Link>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<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>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<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">
{network.scope}
</span>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{network.container_count > 0 ? (
<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">
{network.container_count}
</span>
) : (
<span className="text-secondary-400 dark:text-secondary-500">
0
</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-1">
{network.internal && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
title="Internal"
>
I
</span>
)}
{network.ipv6_enabled && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
title="IPv6 Enabled"
>
6
</span>
)}
{network.ingress && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
title="Swarm Ingress"
>
S
</span>
)}
{!network.internal &&
!network.ipv6_enabled &&
!network.ingress && (
<span className="text-secondary-400 dark:text-secondary-500 text-xs">
-
</span>
)}
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<Link
to={`/hosts/${network.host_id}`}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{network.hosts?.friendly_name ||
network.hosts?.hostname ||
"Unknown"}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-2">
<Link
to={`/docker/networks/${network.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteNetworkModal(network)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
{/* Delete Container Modal */}
{/* Delete Container Modal */}
{deleteContainerModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Container
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this container from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteContainerModal.name}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Image: {deleteContainerModal.image_name}:
{deleteContainerModal.image_tag}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Host:{" "}
{deleteContainerModal.host?.friendly_name || "Unknown"}
</p>
</div>
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
This only removes the container from PatchMon's inventory.
It does NOT stop or delete the actual Docker container on
the host.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() =>
deleteContainerMutation.mutate(deleteContainerModal.id)
}
disabled={deleteContainerMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteContainerMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteContainerModal(null)}
disabled={deleteContainerMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Delete Image Modal */}
{deleteImageModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Image
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this image from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteImageModal.repository}:{deleteImageModal.tag}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Source: {deleteImageModal.source}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Containers using this:{" "}
{deleteImageModal._count?.docker_containers || 0}
</p>
</div>
{deleteImageModal._count?.docker_containers > 0 ? (
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
⚠️ Cannot delete: This image is in use by{" "}
{deleteImageModal._count.docker_containers} container(s).
Delete the containers first.
</p>
) : (
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
⚠️ This only removes the image from PatchMon's inventory.
It does NOT delete the actual Docker image from hosts.
</p>
)}
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() => deleteImageMutation.mutate(deleteImageModal.id)}
disabled={
deleteImageMutation.isPending ||
deleteImageModal._count?.docker_containers > 0
}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteImageMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteImageModal(null)}
disabled={deleteImageMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Delete Volume Modal */}
{deleteVolumeModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Volume
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this volume from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteVolumeModal.name}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Driver: {deleteVolumeModal.driver}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Host:{" "}
{deleteVolumeModal.hosts?.friendly_name ||
deleteVolumeModal.hosts?.hostname ||
"Unknown"}
</p>
{deleteVolumeModal.ref_count > 0 && (
<p className="text-xs text-secondary-600 dark:text-secondary-400">
In use by: {deleteVolumeModal.ref_count} container
{deleteVolumeModal.ref_count !== 1 ? "s" : ""}
</p>
)}
</div>
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
This only removes the volume from PatchMon's inventory. It
does NOT delete the actual Docker volume from the host.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() =>
deleteVolumeMutation.mutate(deleteVolumeModal.id)
}
disabled={deleteVolumeMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteVolumeMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteVolumeModal(null)}
disabled={deleteVolumeMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Delete Network Modal */}
{deleteNetworkModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Network
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this network from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteNetworkModal.name}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Driver: {deleteNetworkModal.driver}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Scope: {deleteNetworkModal.scope}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Host:{" "}
{deleteNetworkModal.hosts?.friendly_name ||
deleteNetworkModal.hosts?.hostname ||
"Unknown"}
</p>
{deleteNetworkModal.container_count > 0 && (
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Connected containers:{" "}
{deleteNetworkModal.container_count}
</p>
)}
</div>
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
⚠️ This only removes the network from PatchMon's inventory.
It does NOT delete the actual Docker network from the host.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() =>
deleteNetworkMutation.mutate(deleteNetworkModal.id)
}
disabled={deleteNetworkMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteNetworkMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteNetworkModal(null)}
disabled={deleteNetworkMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Docker;