add ports column to containers table (#1481)

This commit is contained in:
henrygd
2026-03-14 19:29:39 -04:00
parent a7f99e7a8c
commit 380d2b1091
9 changed files with 224 additions and 22 deletions

View File

@@ -16,6 +16,8 @@ import (
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -346,6 +348,33 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo
stats.PrevReadTime = readTime
}
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
func convertContainerPortsToString(ctr *container.ApiInfo) string {
if len(ctr.Ports) == 0 {
return ""
}
sort.Slice(ctr.Ports, func(i, j int) bool {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
})
var builder strings.Builder
seenPorts := make(map[uint16]struct{})
for _, p := range ctr.Ports {
_, ok := seenPorts[p.PublicPort]
if p.PublicPort == 0 || ok {
continue
}
seenPorts[p.PublicPort] = struct{}{}
if builder.Len() > 0 {
builder.WriteString(", ")
}
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
}
// clear ports slice so it doesn't get reused and blend into next response
ctr.Ports = nil
return builder.String()
}
func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status)
if trimmed == "" {
@@ -414,6 +443,10 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
stats.Status = statusText
stats.Health = health
if len(ctr.Ports) > 0 {
stats.Ports = convertContainerPortsToString(ctr)
}
// reset current stats
stats.Cpu = 0
stats.Mem = 0

View File

@@ -1455,3 +1455,109 @@ func TestAnsiEscapePattern(t *testing.T) {
})
}
}
func TestConvertContainerPortsToString(t *testing.T) {
type port = struct {
PrivatePort uint16
PublicPort uint16
Type string
}
tests := []struct {
name string
ports []port
expected string
}{
{
name: "empty ports",
ports: nil,
expected: "",
},
{
name: "single port public==private",
ports: []port{
{PublicPort: 80, PrivatePort: 80},
},
expected: "80",
},
{
name: "single port public!=private",
ports: []port{
{PublicPort: 443, PrivatePort: 2019},
},
// expected: "443:2019",
expected: "443",
},
{
name: "zero PublicPort is skipped",
ports: []port{
{PublicPort: 0, PrivatePort: 8080},
{PublicPort: 80, PrivatePort: 80},
},
expected: "80",
},
{
name: "ports sorted ascending by PublicPort",
ports: []port{
{PublicPort: 443, PrivatePort: 443},
{PublicPort: 80, PrivatePort: 80},
{PublicPort: 8080, PrivatePort: 8080},
},
expected: "80, 443, 8080",
},
{
name: "same PublicPort sorted by PrivatePort",
ports: []port{
{PublicPort: 443, PrivatePort: 9000},
{PublicPort: 443, PrivatePort: 2019},
},
// expected: "443:2019,443:9000",
expected: "443",
},
{
name: "duplicates are deduplicated",
ports: []port{
{PublicPort: 80, PrivatePort: 80},
{PublicPort: 80, PrivatePort: 80},
{PublicPort: 443, PrivatePort: 2019},
{PublicPort: 443, PrivatePort: 2019},
},
// expected: "80,443:2019",
expected: "80, 443",
},
{
name: "mixed zero and non-zero ports",
ports: []port{
{PublicPort: 0, PrivatePort: 5432},
{PublicPort: 443, PrivatePort: 2019},
{PublicPort: 80, PrivatePort: 80},
{PublicPort: 0, PrivatePort: 9000},
},
// expected: "80,443:2019",
expected: "80, 443",
},
{
name: "ports slice is nilled after call",
ports: []port{
{PublicPort: 8080, PrivatePort: 8080},
},
expected: "8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctr := &container.ApiInfo{}
for _, p := range tt.ports {
ctr.Ports = append(ctr.Ports, struct {
// PrivatePort uint16
PublicPort uint16
// Type string
}{PublicPort: p.PublicPort})
}
result := convertContainerPortsToString(ctr)
assert.Equal(t, tt.expected, result)
// Ports slice must be cleared to prevent bleed-over into the next response
assert.Nil(t, ctr.Ports, "ctr.Ports should be nil after formatContainerPorts")
})
}
}