diff --git a/agent/docker.go b/agent/docker.go index 75d628a7..bcafcb1e 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -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 diff --git a/agent/docker_test.go b/agent/docker_test.go index 75973a60..23fecdcd 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -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") + }) + } +} diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index 6c942085..82c101fd 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -14,10 +14,15 @@ type ApiInfo struct { Status string // FailingStreak int } + Ports []struct { + // PrivatePort uint16 + PublicPort uint16 + // IP string + // Type string + } // ImageID string // Command string // Created int64 - // Ports []Port // SizeRw int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"` // Labels map[string]string @@ -144,6 +149,7 @@ type Stats struct { Status string `json:"-" cbor:"6,keyasint"` Id string `json:"-" cbor:"7,keyasint"` Image string `json:"-" cbor:"8,keyasint"` + Ports string `json:"-" cbor:"10,keyasint"` // PrevCpu [2]uint64 `json:"-"` CpuSystem uint64 `json:"-"` CpuContainer uint64 `json:"-"` diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 564d008b..d285bab7 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -318,10 +318,11 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri valueStrings := make([]string, 0, len(data)) for i, container := range data { suffix := fmt.Sprintf("%d", i) - valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) + valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) params["id"+suffix] = container.Id params["name"+suffix] = container.Name params["image"+suffix] = container.Image + params["ports"+suffix] = container.Ports params["status"+suffix] = container.Status params["health"+suffix] = container.Health params["cpu"+suffix] = container.Cpu @@ -333,7 +334,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri params["net"+suffix] = netBytes } queryString := fmt.Sprintf( - "INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated", + "INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated", strings.Join(valueStrings, ","), ) _, err := app.DB().NewQuery(queryString).Bind(params).Execute() diff --git a/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go similarity index 99% rename from internal/migrations/0_collections_snapshot_0_18_0_dev_2.go rename to internal/migrations/0_collections_snapshot_0_19_0_dev_1.go index 79800649..eb8cea23 100644 --- a/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go +++ b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go @@ -977,18 +977,6 @@ func init() { "system": false, "type": "number" }, - { - "hidden": false, - "id": "number3332085495", - "max": null, - "min": null, - "name": "updated", - "onlyInt": true, - "presentable": false, - "required": true, - "system": false, - "type": "number" - }, { "autogeneratePattern": "", "hidden": false, @@ -1002,6 +990,32 @@ func init() { "required": false, "system": false, "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2308952269", + "max": 0, + "min": 0, + "name": "ports", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3332085495", + "max": null, + "min": null, + "name": "updated", + "onlyInt": true, + "presentable": false, + "required": true, + "system": false, + "type": "number" } ], "indexes": [ diff --git a/internal/site/src/components/containers-table/containers-table-columns.tsx b/internal/site/src/components/containers-table/containers-table-columns.tsx index a356b02c..17fae3cf 100644 --- a/internal/site/src/components/containers-table/containers-table-columns.tsx +++ b/internal/site/src/components/containers-table/containers-table-columns.tsx @@ -4,7 +4,6 @@ import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils" import type { ContainerRecord } from "@/types" import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { - ArrowUpDownIcon, ClockIcon, ContainerIcon, CpuIcon, @@ -13,11 +12,12 @@ import { ServerIcon, ShieldCheckIcon, } from "lucide-react" -import { EthernetIcon, HourglassIcon } from "../ui/icons" +import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons" import { Badge } from "../ui/badge" import { t } from "@lingui/core/macro" import { $allSystemsById } from "@/lib/stores" import { useStore } from "@nanostores/react" +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" // Unit names and their corresponding number of seconds for converting docker status strings const unitSeconds = [ @@ -134,6 +134,29 @@ export const containerChartCols: ColumnDef[] = [ ) }, }, + { + id: "ports", + accessorFn: (record) => record.ports || undefined, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const val = getValue() as string + if (!val) { + return - + } + const className = "ms-1.5 w-20 block truncate tabular-nums" + if (val.length > 9) { + return ( + + {val} + {val} + + ) + } + return {val} + }, + }, { id: "image", sortingFn: (a, b) => a.original.image.localeCompare(b.original.image), @@ -188,7 +211,7 @@ function HeaderButton({ > {Icon && } {name} - + {/* */} ) } diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx index 773d478d..42906e25 100644 --- a/internal/site/src/components/containers-table/containers-table.tsx +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -51,7 +51,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) { function fetchData(systemId?: string) { pb.collection("containers") .getList(0, 2000, { - fields: "id,name,image,cpu,memory,net,health,status,system,updated", + fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated", filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, }) .then(({ items }) => { @@ -134,7 +134,8 @@ export default function ContainersTable({ systemId }: { systemId?: string }) { const status = container.status ?? "" const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" const image = container.image ?? "" - const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase() + const ports = container.ports ?? "" + const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image} ${ports}`.toLowerCase() return (filterValue as string) .toLowerCase() @@ -378,8 +379,14 @@ function ContainerSheet({ {container.image} {container.id} - - {ContainerHealthLabels[container.health as ContainerHealth]} + {/* {container.ports && ( + <> + + {container.ports} + + )} */} + {/* + {ContainerHealthLabels[container.health as ContainerHealth]} */}
diff --git a/internal/site/src/components/ui/icons.tsx b/internal/site/src/components/ui/icons.tsx index b323ab83..f92259bd 100644 --- a/internal/site/src/components/ui/icons.tsx +++ b/internal/site/src/components/ui/icons.tsx @@ -185,3 +185,14 @@ export function PlugChargingIcon(props: SVGProps) { ) } + +// Lucide Icons (ISC) - used for ports +export function SquareArrowRightEnterIcon(props: SVGProps) { + return ( + + + + + + ) +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index fe98e4f3..122882b5 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -254,6 +254,7 @@ export interface ContainerRecord extends RecordModel { system: string name: string image: string + ports: string cpu: number memory: number net: number