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.
This commit is contained in:
443
aggregator-server/internal/api/handlers/docker.go
Normal file
443
aggregator-server/internal/api/handlers/docker.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
84
aggregator-server/internal/models/docker.go
Normal file
84
aggregator-server/internal/models/docker.go
Normal file
@@ -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"`
|
||||
}
|
||||
461
aggregator-web/src/pages/Docker.tsx
Normal file
461
aggregator-web/src/pages/Docker.tsx
Normal file
@@ -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<string[]>([]);
|
||||
|
||||
// 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<string, { agentId: string; agentName: string; containers: DockerContainer[] }>);
|
||||
|
||||
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 (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<Container className="w-8 h-8 mr-3 text-blue-600" />
|
||||
Docker Containers
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Manage container image updates across all agents
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">
|
||||
{totalCount} container images found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Images</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalCount}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Updates Available</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{images.filter((i: DockerImage) => i.update_available).length}</p>
|
||||
</div>
|
||||
<Container className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending Approval</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{images.filter((i: DockerImage) => i.status === 'update-available').length}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Critical Updates</p>
|
||||
<p className="text-2xl font-bold text-red-600">{images.filter((i: DockerImage) => i.severity === 'critical').length}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleQuickFilter('all')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
!statusFilter && !severityFilter && !searchQuery
|
||||
? "bg-blue-100 border-blue-300 text-blue-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
All Images
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('pending')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'update-available' && !severityFilter && !searchQuery
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
Pending Approval
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('critical')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
searchQuery === 'critical' && !statusFilter && !severityFilter
|
||||
? "bg-red-100 border-red-300 text-red-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
Critical Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{statuses.map((status: string) => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{severities.map((severity: string) => (
|
||||
<option key={severity} value={severity}>{severity}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container updates table */}
|
||||
{isPending ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load container updates</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Container className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No container images found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter || severityFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No Docker containers or images found on any agents.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{agentGroups.map((agentGroup) => (
|
||||
<div key={agentGroup.agentId} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* Agent Header */}
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Container className="w-6 h-6 mr-3 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{agentGroup.agentName}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{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`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{agentGroup.containers.filter(c => c.update_available).length > 0 && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
Updates Available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Containers for this agent */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Container Image
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Versions
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ports
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Severity
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Discovered
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{agentGroup.containers.map((container) => (
|
||||
<tr key={container.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Container className="w-6 h-6 mr-3 text-blue-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{container.image}:{container.tag}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{container.container_id !== container.image && container.container_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm">
|
||||
{container.update_available ? (
|
||||
<>
|
||||
<div className="text-gray-900">{container.current_version}</div>
|
||||
<div className="text-green-600 font-medium">→ {container.available_version}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-900">{container.current_version || container.tag}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500 font-mono">
|
||||
{formatPorts(container.ports)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn('inline-flex px-2 py-1 text-xs font-semibold rounded-full', getSeverityColor('medium'))}>
|
||||
{/* Docker updates are typically medium or low severity by default */}
|
||||
{'medium'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn('inline-flex px-2 py-1 text-xs font-semibold rounded-full', getStatusColor(container.status))}>
|
||||
{container.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatRelativeTime(container.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Docker;
|
||||
Reference in New Issue
Block a user