From 552f14f99a6f2611cbcaa33d3327b68873f45563 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Thu, 16 Oct 2025 08:07:54 -0400 Subject: [PATCH] feat: Implement agent-grouped Docker interface with port information Add comprehensive Docker container management with agent-centric organization: Backend enhancements: - Add DockerPort struct for container port mappings - Extend DockerContainer model with agent hostname and ports - Enhance Docker handlers to fetch agent information - Extract port data from container metadata - Support both container and host port display Frontend improvements: - Group containers by agent with clear visual separation - Display agent hostnames instead of UUIDs - Add dedicated Ports column with formatted mappings - Show container counts and update status per agent - Improve version delta display with visual indicators This provides a much more intuitive interface for managing Docker containers across multiple agents while maintaining compatibility with existing approval workflows. --- .../internal/api/handlers/docker.go | 443 +++++++++++++++++ aggregator-server/internal/models/docker.go | 84 ++++ aggregator-web/src/pages/Docker.tsx | 461 ++++++++++++++++++ 3 files changed, 988 insertions(+) create mode 100644 aggregator-server/internal/api/handlers/docker.go create mode 100644 aggregator-server/internal/models/docker.go create mode 100644 aggregator-web/src/pages/Docker.tsx diff --git a/aggregator-server/internal/api/handlers/docker.go b/aggregator-server/internal/api/handlers/docker.go new file mode 100644 index 0000000..4c92607 --- /dev/null +++ b/aggregator-server/internal/api/handlers/docker.go @@ -0,0 +1,443 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type DockerHandler struct { + updateQueries *queries.UpdateQueries + agentQueries *queries.AgentQueries + commandQueries *queries.CommandQueries +} + +func NewDockerHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *DockerHandler { + return &DockerHandler{ + updateQueries: uq, + agentQueries: aq, + commandQueries: cq, + } +} + +// GetContainers returns Docker containers and images across all agents +func (h *DockerHandler) GetContainers(c *gin.Context) { + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + agentID := c.Query("agent") + status := c.Query("status") + + filters := &models.UpdateFilters{ + PackageType: "docker_image", + Page: page, + PageSize: pageSize, + Status: status, + } + + // Parse agent_id if provided + if agentID != "" { + if parsedID, err := uuid.Parse(agentID); err == nil { + filters.AgentID = parsedID + } + } + + // Get Docker updates (which represent container images) + updates, total, err := h.updateQueries.ListUpdatesFromState(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker containers"}) + return + } + + // Get agent information for better display + agentMap := make(map[uuid.UUID]models.Agent) + for _, update := range updates { + if _, exists := agentMap[update.AgentID]; !exists { + if agent, err := h.agentQueries.GetAgentByID(update.AgentID); err == nil { + agentMap[update.AgentID] = *agent + } + } + } + + // Transform updates into Docker container format + containers := make([]models.DockerContainer, 0, len(updates)) + uniqueImages := make(map[string]bool) + + for _, update := range updates { + // Extract container info from update metadata + containerName := update.PackageName + var ports []models.DockerPort + + if update.Metadata != nil { + if name, exists := update.Metadata["container_name"]; exists { + if nameStr, ok := name.(string); ok { + containerName = nameStr + } + } + + // Extract port information from metadata + if portsData, exists := update.Metadata["ports"]; exists { + if portsArray, ok := portsData.([]interface{}); ok { + for _, portData := range portsArray { + if portMap, ok := portData.(map[string]interface{}); ok { + port := models.DockerPort{} + if cp, ok := portMap["container_port"].(float64); ok { + port.ContainerPort = int(cp) + } + if hp, ok := portMap["host_port"].(float64); ok { + hostPort := int(hp) + port.HostPort = &hostPort + } + if proto, ok := portMap["protocol"].(string); ok { + port.Protocol = proto + } + if ip, ok := portMap["host_ip"].(string); ok { + port.HostIP = ip + } else { + port.HostIP = "0.0.0.0" + } + ports = append(ports, port) + } + } + } + } + } + + // Get agent information + agentInfo := agentMap[update.AgentID] + + // Create container representation + container := models.DockerContainer{ + ID: update.ID.String(), + ContainerID: containerName, + Image: update.PackageName, + Tag: update.AvailableVersion, // Available version becomes the tag + AgentID: update.AgentID.String(), + AgentName: agentInfo.Hostname, + AgentHostname: agentInfo.Hostname, + Status: update.Status, + State: "", // Could be extracted from metadata if available + Ports: ports, + CreatedAt: update.LastDiscoveredAt, + UpdatedAt: update.LastUpdatedAt, + UpdateAvailable: update.Status != "installed", + CurrentVersion: update.CurrentVersion, + AvailableVersion: update.AvailableVersion, + } + + // Add image to unique set + imageKey := update.PackageName + ":" + update.AvailableVersion + uniqueImages[imageKey] = true + + containers = append(containers, container) + } + + response := models.DockerContainerListResponse{ + Containers: containers, + Images: containers, // Alias for containers to match frontend expectation + TotalImages: len(uniqueImages), + Total: len(containers), + Page: page, + PageSize: pageSize, + TotalPages: (total + pageSize - 1) / pageSize, + } + + c.JSON(http.StatusOK, response) +} + +// GetAgentContainers returns Docker containers for a specific agent +func (h *DockerHandler) GetAgentContainers(c *gin.Context) { + agentIDStr := c.Param("agent_id") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + status := c.Query("status") + + filters := &models.UpdateFilters{ + AgentID: agentID, + PackageType: "docker_image", + Page: page, + PageSize: pageSize, + Status: status, + } + + // Get Docker updates for specific agent + updates, total, err := h.updateQueries.ListUpdatesFromState(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker containers for agent"}) + return + } + + // Get agent information + agentInfo, err := h.agentQueries.GetAgentByID(agentID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + // Transform updates into Docker container format + containers := make([]models.DockerContainer, 0, len(updates)) + uniqueImages := make(map[string]bool) + + for _, update := range updates { + // Extract container info from update metadata + containerName := update.PackageName + var ports []models.DockerPort + + if update.Metadata != nil { + if name, exists := update.Metadata["container_name"]; exists { + if nameStr, ok := name.(string); ok { + containerName = nameStr + } + } + + // Extract port information from metadata + if portsData, exists := update.Metadata["ports"]; exists { + if portsArray, ok := portsData.([]interface{}); ok { + for _, portData := range portsArray { + if portMap, ok := portData.(map[string]interface{}); ok { + port := models.DockerPort{} + if cp, ok := portMap["container_port"].(float64); ok { + port.ContainerPort = int(cp) + } + if hp, ok := portMap["host_port"].(float64); ok { + hostPort := int(hp) + port.HostPort = &hostPort + } + if proto, ok := portMap["protocol"].(string); ok { + port.Protocol = proto + } + if ip, ok := portMap["host_ip"].(string); ok { + port.HostIP = ip + } else { + port.HostIP = "0.0.0.0" + } + ports = append(ports, port) + } + } + } + } + } + + container := models.DockerContainer{ + ID: update.ID.String(), + ContainerID: containerName, + Image: update.PackageName, + Tag: update.AvailableVersion, + AgentID: update.AgentID.String(), + AgentName: agentInfo.Hostname, + AgentHostname: agentInfo.Hostname, + Status: update.Status, + State: "", // Could be extracted from metadata if available + Ports: ports, + CreatedAt: update.LastDiscoveredAt, + UpdatedAt: update.LastUpdatedAt, + UpdateAvailable: update.Status != "installed", + CurrentVersion: update.CurrentVersion, + AvailableVersion: update.AvailableVersion, + } + + imageKey := update.PackageName + ":" + update.AvailableVersion + uniqueImages[imageKey] = true + + containers = append(containers, container) + } + + response := models.DockerContainerListResponse{ + Containers: containers, + Images: containers, // Alias for containers to match frontend expectation + TotalImages: len(uniqueImages), + Total: len(containers), + Page: page, + PageSize: pageSize, + TotalPages: (total + pageSize - 1) / pageSize, + } + + c.JSON(http.StatusOK, response) +} + +// GetStats returns Docker statistics across all agents +func (h *DockerHandler) GetStats(c *gin.Context) { + // Get all Docker updates + filters := &models.UpdateFilters{ + PackageType: "docker_image", + Page: 1, + PageSize: 10000, // Get all for stats + } + + updates, _, err := h.updateQueries.ListUpdatesFromState(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker stats"}) + return + } + + stats := models.DockerStats{ + TotalContainers: len(updates), + TotalImages: 0, + UpdatesAvailable: 0, + PendingApproval: 0, + CriticalUpdates: 0, + } + + // Calculate stats + uniqueImages := make(map[string]bool) + agentsWithContainers := make(map[uuid.UUID]bool) + + for _, update := range updates { + // Count unique images + imageKey := update.PackageName + ":" + update.AvailableVersion + uniqueImages[imageKey] = true + + // Count agents with containers + agentsWithContainers[update.AgentID] = true + + // Count updates available + if update.Status != "installed" { + stats.UpdatesAvailable++ + } + + // Count pending approval + if update.Status == "pending_approval" { + stats.PendingApproval++ + } + + // Count critical updates + if update.Severity == "critical" { + stats.CriticalUpdates++ + } + } + + stats.TotalImages = len(uniqueImages) + stats.AgentsWithContainers = len(agentsWithContainers) + + c.JSON(http.StatusOK, stats) +} + +// ApproveUpdate approves a Docker image update +func (h *DockerHandler) ApproveUpdate(c *gin.Context) { + containerID := c.Param("container_id") + imageID := c.Param("image_id") + + if containerID == "" || imageID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "container_id and image_id are required"}) + return + } + + // Parse the update ID from container_id (they're the same in our implementation) + updateID, err := uuid.Parse(containerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid container ID"}) + return + } + + // Approve the update + if err := h.updateQueries.ApproveUpdate(updateID, "admin"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve Docker update"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Docker update approved", + "container_id": containerID, + "image_id": imageID, + }) +} + +// RejectUpdate rejects a Docker image update +func (h *DockerHandler) RejectUpdate(c *gin.Context) { + containerID := c.Param("container_id") + imageID := c.Param("image_id") + + if containerID == "" || imageID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "container_id and image_id are required"}) + return + } + + // Parse the update ID + updateID, err := uuid.Parse(containerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid container ID"}) + return + } + + // Get the update details to find the agent ID and package name + update, err := h.updateQueries.GetUpdateByID(updateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + // For now, we'll mark as rejected (this would need a proper reject method in queries) + if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "rejected", nil); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Docker update rejected", + "container_id": containerID, + "image_id": imageID, + }) +} + +// InstallUpdate installs a Docker image update immediately +func (h *DockerHandler) InstallUpdate(c *gin.Context) { + containerID := c.Param("container_id") + imageID := c.Param("image_id") + + if containerID == "" || imageID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "container_id and image_id are required"}) + return + } + + // Parse the update ID + updateID, err := uuid.Parse(containerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid container ID"}) + return + } + + // Get the update details to find the agent ID + update, err := h.updateQueries.GetUpdateByID(updateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + // Create a command for the agent to install the update + // This would trigger the agent to pull the new image + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: update.AgentID, + CommandType: models.CommandTypeScanUpdates, // Reuse scan for Docker updates + Params: models.JSONB{ + "package_type": "docker", + "package_name": update.PackageName, + "target_version": update.AvailableVersion, + "container_id": containerID, + }, + Status: models.CommandStatusPending, + } + + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Docker update command"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Docker update command sent", + "container_id": containerID, + "image_id": imageID, + "command_id": command.ID, + }) +} \ No newline at end of file diff --git a/aggregator-server/internal/models/docker.go b/aggregator-server/internal/models/docker.go new file mode 100644 index 0000000..ef6452d --- /dev/null +++ b/aggregator-server/internal/models/docker.go @@ -0,0 +1,84 @@ +package models + +import ( + "time" +) + +// DockerPort represents a port mapping in a Docker container +type DockerPort struct { + ContainerPort int `json:"container_port"` + HostPort *int `json:"host_port,omitempty"` + Protocol string `json:"protocol"` + HostIP string `json:"host_ip"` +} + +// DockerContainer represents a Docker container with its image information +type DockerContainer struct { + ID string `json:"id"` + ContainerID string `json:"container_id"` + Image string `json:"image"` + Tag string `json:"tag"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` + AgentHostname string `json:"agent_hostname,omitempty"` + Status string `json:"status"` + State string `json:"state,omitempty"` + Ports []DockerPort `json:"ports,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UpdateAvailable bool `json:"update_available"` + CurrentVersion string `json:"current_version,omitempty"` + AvailableVersion string `json:"available_version,omitempty"` +} + +// DockerContainerListResponse represents the response for container listing +type DockerContainerListResponse struct { + Containers []DockerContainer `json:"containers"` + Images []DockerContainer `json:"images"` // Alias for containers to match frontend expectation + TotalImages int `json:"total_images"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// DockerImage represents a Docker image +type DockerImage struct { + ID string `json:"id"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` + UpdateAvailable bool `json:"update_available"` + CurrentVersion string `json:"current_version"` + AvailableVersion string `json:"available_version"` +} + +// DockerStats represents Docker statistics across all agents +type DockerStats struct { + TotalContainers int `json:"total_containers"` + TotalImages int `json:"total_images"` + UpdatesAvailable int `json:"updates_available"` + PendingApproval int `json:"pending_approval"` + CriticalUpdates int `json:"critical_updates"` + AgentsWithContainers int `json:"agents_with_containers"` +} + +// DockerUpdateRequest represents a request to update Docker images +type DockerUpdateRequest struct { + ContainerID string `json:"container_id" binding:"required"` + ImageID string `json:"image_id" binding:"required"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` +} + +// BulkDockerUpdateRequest represents a bulk update request for Docker images +type BulkDockerUpdateRequest struct { + Updates []struct { + ContainerID string `json:"container_id" binding:"required"` + ImageID string `json:"image_id" binding:"required"` + } `json:"updates" binding:"required"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` +} \ No newline at end of file diff --git a/aggregator-web/src/pages/Docker.tsx b/aggregator-web/src/pages/Docker.tsx new file mode 100644 index 0000000..c0c9b83 --- /dev/null +++ b/aggregator-web/src/pages/Docker.tsx @@ -0,0 +1,461 @@ +import React, { useState } from 'react'; +import { Search, Filter, RefreshCw, Package, AlertTriangle, Container, CheckCircle, XCircle, Play } from 'lucide-react'; +import { useDockerContainers, useDockerStats, useApproveDockerUpdate, useRejectDockerUpdate, useInstallDockerUpdate, useBulkDockerActions } from '@/hooks/useDocker'; +import type { DockerContainer, DockerImage } from '@/types'; +import { formatRelativeTime, cn } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +const Docker: React.FC = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [severityFilter, setSeverityFilter] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(50); + const [selectedImages, setSelectedImages] = useState([]); + + // Fetch Docker containers and images + const { data: dockerData, isPending, error } = useDockerContainers({ + search: searchQuery || undefined, + status: statusFilter || undefined, + page: currentPage, + page_size: pageSize, + }); + + const { data: stats } = useDockerStats(); + + const containers = dockerData?.containers || []; + const images = dockerData?.images || []; + const totalCount = dockerData?.total_images || 0; + + // Mutations + const approveUpdate = useApproveDockerUpdate(); + const rejectUpdate = useRejectDockerUpdate(); + const installUpdate = useInstallDockerUpdate(); + const { approveMultiple, rejectMultiple } = useBulkDockerActions(); + + // Group containers by agent for better organization + const containersByAgent = containers.reduce((acc, container) => { + const agentKey = container.agent_id; + if (!acc[agentKey]) { + acc[agentKey] = { + agentId: container.agent_id, + agentName: container.agent_name || container.agent_hostname || `Agent ${container.agent_id.substring(0, 8)}`, + containers: [] + }; + } + acc[agentKey].containers.push(container); + return acc; + }, {} as Record); + + const agentGroups = Object.values(containersByAgent); + + // Get unique values for filters + const statuses = [...new Set(images.map((i: DockerImage) => i.status))]; + const severities = [...new Set(images.map((i: DockerImage) => i.severity).filter(Boolean))]; + const agents = [...new Set(images.map((i: DockerImage) => i.agent_id))]; + + // Quick filter functions + const handleQuickFilter = (filter: string) => { + switch (filter) { + case 'critical': + // Filter to show only images with critical severity updates + setSearchQuery('critical'); + setSeverityFilter(''); + break; + case 'pending': + setStatusFilter('update-available'); + setSeverityFilter(''); + break; + case 'all': + setStatusFilter(''); + setSeverityFilter(''); + setSearchQuery(''); + break; + default: + break; + } + setCurrentPage(1); + }; + + // Handle image selection + const handleSelectImage = (imageId: string, checked: boolean) => { + if (checked) { + setSelectedImages([...selectedImages, imageId]); + } else { + setSelectedImages(selectedImages.filter(id => id !== imageId)); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedImages(images.map((image: DockerImage) => image.id)); + } else { + setSelectedImages([]); + } + }; + + // Handle bulk actions + const handleBulkApprove = async () => { + if (selectedImages.length === 0) { + toast.error('Please select at least one image'); + return; + } + + const updates = selectedImages.map(imageId => { + const image = images.find(img => img.id === imageId); + return { + containerId: image?.agent_id || '', // Use agent_id as containerId for now + imageId: imageId, + }; + }); + + try { + await approveMultiple.mutateAsync({ updates }); + setSelectedImages([]); + } catch (error) { + // Error handling is done in the hook + } + }; + + // Format bytes utility + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + // Format port information for display + const formatPorts = (ports: any[]): string => { + if (!ports || ports.length === 0) return '-'; + + return ports.map(port => { + const hostPort = port.host_port ? `:${port.host_port}` : ''; + return `${port.container_port}${hostPort}/${port.protocol}`; + }).join(', '); + }; + + // Helper functions for status and severity colors + const getStatusColor = (status: string): string => { + switch (status) { + case 'up-to-date': + return 'bg-green-100 text-green-800'; + case 'update-available': + return 'bg-blue-100 text-blue-800'; + case 'update-approved': + return 'bg-orange-100 text-orange-800'; + case 'update-scheduled': + return 'bg-purple-100 text-purple-800'; + case 'update-installing': + return 'bg-yellow-100 text-yellow-800'; + case 'update-failed': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getSeverityColor = (severity: string): string => { + switch (severity) { + case 'low': + return 'bg-gray-100 text-gray-800'; + case 'medium': + return 'bg-blue-100 text-blue-800'; + case 'high': + return 'bg-orange-100 text-orange-800'; + case 'critical': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+

+ + Docker Containers +

+

+ Manage container image updates across all agents +

+
+
+
+ {totalCount} container images found +
+
+
+ + {/* Statistics Cards */} +
+
+
+
+

Total Images

+

{totalCount}

+
+ +
+
+ +
+
+
+

Updates Available

+

{images.filter((i: DockerImage) => i.update_available).length}

+
+ +
+
+ +
+
+
+

Pending Approval

+

{images.filter((i: DockerImage) => i.status === 'update-available').length}

+
+ +
+
+ +
+
+
+

Critical Updates

+

{images.filter((i: DockerImage) => i.severity === 'critical').length}

+
+ +
+
+
+ + {/* Quick Filters */} +
+ + + +
+
+ + {/* Search and filters */} +
+
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search container images..." + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + {/* Filters */} +
+ + + +
+
+
+ + {/* Container updates table */} + {isPending ? ( +
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ) : error ? ( +
+
Failed to load container updates
+

Please check your connection and try again.

+
+ ) : images.length === 0 ? ( +
+ +

No container images found

+

+ {searchQuery || statusFilter || severityFilter + ? 'Try adjusting your search or filters.' + : 'No Docker containers or images found on any agents.'} +

+
+ ) : ( +
+ {agentGroups.map((agentGroup) => ( +
+ {/* Agent Header */} +
+
+
+ +
+

{agentGroup.agentName}

+

+ {agentGroup.containers.length} container image{agentGroup.containers.length !== 1 ? 's' : ''} + {agentGroup.containers.filter(c => c.update_available).length > 0 && + ` • ${agentGroup.containers.filter(c => c.update_available).length} update${agentGroup.containers.filter(c => c.update_available).length !== 1 ? 's' : ''} available` + } +

+
+
+
+ {agentGroup.containers.filter(c => c.update_available).length > 0 && ( + + Updates Available + + )} +
+
+
+ + {/* Containers for this agent */} +
+ + + + + + + + + + + + + {agentGroup.containers.map((container) => ( + + + + + + + + + ))} + +
+ Container Image + + Versions + + Ports + + Severity + + Status + + Discovered +
+
+ +
+
+ {container.image}:{container.tag} +
+
+ {container.container_id !== container.image && container.container_id} +
+
+
+
+
+ {container.update_available ? ( + <> +
{container.current_version}
+
→ {container.available_version}
+ + ) : ( +
{container.current_version || container.tag}
+ )} +
+
+
+ {formatPorts(container.ports)} +
+
+ + {/* Docker updates are typically medium or low severity by default */} + {'medium'} + + + + {container.status} + + + {formatRelativeTime(container.created_at)} +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default Docker; \ No newline at end of file