package compose import ( "fmt" "sort" "strings" ) // ProjectSummary is the operator-facing view of one Compose project: // counts, per-service containers, grouped mounts, and Traefik routes. // It is the shape rendered on /compose and returned by the JSON API. type ProjectSummary struct { Name string `json:"name"` Status string `json:"status"` ConfigFiles string `json:"config_files"` WorkingDir string `json:"working_dir"` ServiceCount int `json:"service_count"` ContainerCount int `json:"container_count"` RunningCount int `json:"running_count"` Services []ServiceSummary `json:"services"` GroupedMounts []ProjectMountInfo `json:"grouped_mounts"` AllTraefikRoutes []ProjectTraefikRoute `json:"traefik_routes"` } // ServiceSummary is a condensed view of a Compose service. type ServiceSummary struct { Name string `json:"name"` Containers []ContainerSummary `json:"containers"` } // ContainerSummary holds the key container fields the detail page shows. type ContainerSummary struct { Name string `json:"name"` ID string `json:"id"` Image string `json:"image"` State string `json:"state"` Status string `json:"status"` Health string `json:"health"` PID int `json:"pid"` Ports []PortBinding `json:"ports"` } // ProjectMountInfo represents a mount that may be shared across the // services/containers of a project. type ProjectMountInfo struct { Source string `json:"source"` Destination string `json:"destination"` Type string `json:"type"` UsedBy []MountUsage `json:"used_by"` } // MountUsage identifies which service/container uses a mount. type MountUsage struct { Service string `json:"service"` Container string `json:"container"` } // ProjectTraefikRoute is a Traefik route attributed to the project. type ProjectTraefikRoute struct { RouterName string `json:"router_name"` Service string `json:"service"` Container string `json:"container"` Hostnames []string `json:"hostnames"` PathPrefixes []string `json:"path_prefixes"` Paths []string `json:"paths"` Rule string `json:"rule"` } // BuildProjectSummary creates the operator-facing summary from a raw // Project. Services, mounts, and routes are sorted for stable display. func BuildProjectSummary(p Project) ProjectSummary { s := ProjectSummary{ Name: p.Name, Status: p.Status, ConfigFiles: p.ConfigFiles, WorkingDir: p.WorkingDir, Services: make([]ServiceSummary, 0, len(p.Services)), GroupedMounts: make([]ProjectMountInfo, 0), AllTraefikRoutes: make([]ProjectTraefikRoute, 0), } mountGroups := map[string]*ProjectMountInfo{} for svcName, svc := range p.Services { s.ServiceCount++ svcSummary := ServiceSummary{Name: svcName, Containers: make([]ContainerSummary, 0, len(svc.Containers))} for _, c := range svc.Containers { s.ContainerCount++ if c.State == "running" { s.RunningCount++ } svcSummary.Containers = append(svcSummary.Containers, ContainerSummary{ Name: c.Name, ID: c.ID, Image: c.Image, State: c.State, Status: c.Status, Health: c.Health, PID: c.PID, Ports: c.Ports, }) groupMount(mountGroups, svcName, c) for _, r := range ExtractTraefikRoutes(c) { s.AllTraefikRoutes = append(s.AllTraefikRoutes, ProjectTraefikRoute{ RouterName: r.RouterName, Service: svcName, Container: c.Name, Hostnames: r.Hostnames, PathPrefixes: r.PathPrefixes, Paths: r.Paths, Rule: r.Rule, }) } } s.Services = append(s.Services, svcSummary) } for _, mi := range mountGroups { s.GroupedMounts = append(s.GroupedMounts, *mi) } sort.Slice(s.Services, func(i, j int) bool { return s.Services[i].Name < s.Services[j].Name }) sort.Slice(s.GroupedMounts, func(i, j int) bool { return s.GroupedMounts[i].Source < s.GroupedMounts[j].Source }) sort.Slice(s.AllTraefikRoutes, func(i, j int) bool { return s.AllTraefikRoutes[i].RouterName < s.AllTraefikRoutes[j].RouterName }) return s } func groupMount(groups map[string]*ProjectMountInfo, svc string, c Container) { for _, m := range c.Mounts { key := fmt.Sprintf("%s|%s|%s", m.Source, m.Destination, m.Type) usage := MountUsage{Service: svc, Container: c.Name} if existing := groups[key]; existing != nil { for _, u := range existing.UsedBy { if u == usage { goto next } } existing.UsedBy = append(existing.UsedBy, usage) next: continue } groups[key] = &ProjectMountInfo{ Source: m.Source, Destination: m.Destination, Type: m.Type, UsedBy: []MountUsage{usage}, } } } // BuildAllProjectSummaries returns every project summary sorted by name. func BuildAllProjectSummaries(res *DiscoveryResult) []ProjectSummary { out := make([]ProjectSummary, 0, len(res.Projects)) for _, p := range res.Projects { out = append(out, BuildProjectSummary(p)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // FormatMountSourceShort shortens a long mount source for table display. func FormatMountSourceShort(source string, maxLen int) string { if len(source) <= maxLen || maxLen < 10 { return source } keep := (maxLen - 3) / 2 return source[:keep] + "..." + source[len(source)-keep:] } // SharedMountSummary returns a short label describing who uses a mount. func (m *ProjectMountInfo) SharedMountSummary() string { if len(m.UsedBy) == 0 { return "unused" } if len(m.UsedBy) == 1 { return fmt.Sprintf("%s/%s", m.UsedBy[0].Service, m.UsedBy[0].Container) } counts := map[string]int{} for _, u := range m.UsedBy { counts[u.Service]++ } var parts []string for svc, n := range counts { if n == 1 { parts = append(parts, svc) } else { parts = append(parts, fmt.Sprintf("%s(%d)", svc, n)) } } sort.Strings(parts) return "shared: " + strings.Join(parts, ", ") } // FindProject returns the summary for the named project, or nil. func (s Snapshot) FindProject(name string) *ProjectSummary { for i := range s.Projects { if s.Projects[i].Name == name { return &s.Projects[i] } } return nil }