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" "os"
"path" "path"
"regexp" "regexp"
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -346,6 +348,33 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo
stats.PrevReadTime = readTime 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) { func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status) trimmed := strings.TrimSpace(status)
if trimmed == "" { if trimmed == "" {
@@ -414,6 +443,10 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
stats.Status = statusText stats.Status = statusText
stats.Health = health stats.Health = health
if len(ctr.Ports) > 0 {
stats.Ports = convertContainerPortsToString(ctr)
}
// reset current stats // reset current stats
stats.Cpu = 0 stats.Cpu = 0
stats.Mem = 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")
})
}
}

View File

@@ -14,10 +14,15 @@ type ApiInfo struct {
Status string Status string
// FailingStreak int // FailingStreak int
} }
Ports []struct {
// PrivatePort uint16
PublicPort uint16
// IP string
// Type string
}
// ImageID string // ImageID string
// Command string // Command string
// Created int64 // Created int64
// Ports []Port
// SizeRw int64 `json:",omitempty"` // SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string // Labels map[string]string
@@ -144,6 +149,7 @@ type Stats struct {
Status string `json:"-" cbor:"6,keyasint"` Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"` Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"` Image string `json:"-" cbor:"8,keyasint"`
Ports string `json:"-" cbor:"10,keyasint"`
// PrevCpu [2]uint64 `json:"-"` // PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"` CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"` CpuContainer uint64 `json:"-"`

View File

@@ -318,10 +318,11 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
valueStrings := make([]string, 0, len(data)) valueStrings := make([]string, 0, len(data))
for i, container := range data { for i, container := range data {
suffix := fmt.Sprintf("%d", i) 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["id"+suffix] = container.Id
params["name"+suffix] = container.Name params["name"+suffix] = container.Name
params["image"+suffix] = container.Image params["image"+suffix] = container.Image
params["ports"+suffix] = container.Ports
params["status"+suffix] = container.Status params["status"+suffix] = container.Status
params["health"+suffix] = container.Health params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu params["cpu"+suffix] = container.Cpu
@@ -333,7 +334,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
params["net"+suffix] = netBytes params["net"+suffix] = netBytes
} }
queryString := fmt.Sprintf( 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, ","), strings.Join(valueStrings, ","),
) )
_, err := app.DB().NewQuery(queryString).Bind(params).Execute() _, err := app.DB().NewQuery(queryString).Bind(params).Execute()

View File

@@ -977,18 +977,6 @@ func init() {
"system": false, "system": false,
"type": "number" "type": "number"
}, },
{
"hidden": false,
"id": "number3332085495",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{ {
"autogeneratePattern": "", "autogeneratePattern": "",
"hidden": false, "hidden": false,
@@ -1002,6 +990,32 @@ func init() {
"required": false, "required": false,
"system": false, "system": false,
"type": "text" "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": [ "indexes": [

View File

@@ -4,7 +4,6 @@ import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types" import type { ContainerRecord } from "@/types"
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import { import {
ArrowUpDownIcon,
ClockIcon, ClockIcon,
ContainerIcon, ContainerIcon,
CpuIcon, CpuIcon,
@@ -13,11 +12,12 @@ import {
ServerIcon, ServerIcon,
ShieldCheckIcon, ShieldCheckIcon,
} from "lucide-react" } from "lucide-react"
import { EthernetIcon, HourglassIcon } from "../ui/icons" import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores" import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react" 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 // Unit names and their corresponding number of seconds for converting docker status strings
const unitSeconds = [ const unitSeconds = [
@@ -134,6 +134,29 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
) )
}, },
}, },
{
id: "ports",
accessorFn: (record) => record.ports || undefined,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Ports", context: "Container ports" })} Icon={SquareArrowRightEnterIcon} />
),
cell: ({ getValue }) => {
const val = getValue() as string
if (!val) {
return <span className="ms-2">-</span>
}
const className = "ms-1.5 w-20 block truncate tabular-nums"
if (val.length > 9) {
return (
<Tooltip>
<TooltipTrigger className={className}>{val}</TooltipTrigger>
<TooltipContent>{val}</TooltipContent>
</Tooltip>
)
}
return <span className={className}>{val}</span>
},
},
{ {
id: "image", id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image), sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
@@ -188,7 +211,7 @@ function HeaderButton({
> >
{Icon && <Icon className="size-4" />} {Icon && <Icon className="size-4" />}
{name} {name}
<ArrowUpDownIcon className="size-4" /> {/* <ArrowUpDownIcon className="size-4" /> */}
</Button> </Button>
) )
} }

View File

@@ -51,7 +51,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
function fetchData(systemId?: string) { function fetchData(systemId?: string) {
pb.collection<ContainerRecord>("containers") pb.collection<ContainerRecord>("containers")
.getList(0, 2000, { .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, filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
}) })
.then(({ items }) => { .then(({ items }) => {
@@ -134,7 +134,8 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
const status = container.status ?? "" const status = container.status ?? ""
const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? ""
const image = container.image ?? "" 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) return (filterValue as string)
.toLowerCase() .toLowerCase()
@@ -378,8 +379,14 @@ function ContainerSheet({
{container.image} {container.image}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" /> <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.id} {container.id}
{/* {container.ports && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" /> <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{ContainerHealthLabels[container.health as ContainerHealth]} {container.ports}
</>
)} */}
{/* <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{ContainerHealthLabels[container.health as ContainerHealth]} */}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start"> <div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">

View File

@@ -185,3 +185,14 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
// Lucide Icons (ISC) - used for ports
export function SquareArrowRightEnterIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<path d="m10 16 4-4-4-4" />
<path d="M3 12h11" />
<path d="M3 8V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3" />
</svg>
)
}

View File

@@ -254,6 +254,7 @@ export interface ContainerRecord extends RecordModel {
system: string system: string
name: string name: string
image: string image: string
ports: string
cpu: number cpu: number
memory: number memory: number
net: number net: number