From d7174df58893bba276a698f0df208559df909536 Mon Sep 17 00:00:00 2001 From: kostyay Date: Wed, 11 Feb 2026 11:05:57 +0100 Subject: [PATCH 1/3] feat: add Docker container column in drill-down view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When drilling into a Docker process (com.docker.backend, dockerd, etc.), an extra "Container" column appears showing container name, image, and port mapping (e.g., "nginx (nginx:latest) 8080→80"). - Add internal/docker package with Resolver using Docker Engine API - Add ContainerInfo/PortMapping types to model - Detect Docker processes on drill-down, resolve ports to containers - Render Container column with flex layout, sortable via sort mode - Graceful degradation when Docker is unavailable - Comprehensive tests: unit, sort, view rendering, integration flow Co-Authored-By: Claude Opus 4.6 --- ...26-02-11-docker-container-column-design.md | 178 +++++++++ go.mod | 27 ++ go.sum | 139 +++++-- internal/docker/resolver.go | 124 ++++++ internal/docker/resolver_test.go | 362 ++++++++++++++++++ internal/model/network.go | 46 ++- internal/model/network_test.go | 132 +++++++ internal/ui/messages.go | 7 + internal/ui/mock_collector_test.go | 16 + internal/ui/model.go | 12 + internal/ui/update.go | 42 +- internal/ui/update_test.go | 295 ++++++++++++++ internal/ui/view.go | 64 +++- internal/ui/view_sort.go | 17 + internal/ui/view_sort_test.go | 142 +++++++ internal/ui/view_table.go | 27 ++ internal/ui/view_test.go | 137 +++++++ 17 files changed, 1703 insertions(+), 64 deletions(-) create mode 100644 docs/plans/2026-02-11-docker-container-column-design.md create mode 100644 internal/docker/resolver.go create mode 100644 internal/docker/resolver_test.go diff --git a/docs/plans/2026-02-11-docker-container-column-design.md b/docs/plans/2026-02-11-docker-container-column-design.md new file mode 100644 index 0000000..33d59d5 --- /dev/null +++ b/docs/plans/2026-02-11-docker-container-column-design.md @@ -0,0 +1,178 @@ +# Docker Container Column + +## Summary + +When drilling into a Docker process (`com.docker.backend`, `dockerd`, `docker-proxy`, `containerd`), show an extra "Container" column in the connections table. Column displays: `name (image) hostPort→containerPort`. + +## Decisions + +- **Display**: Extra column in connections table (not a sub-grouping or separate view) +- **Visibility**: Docker processes only; non-Docker views unchanged +- **Data source**: Docker Engine API via `github.com/docker/docker/client` +- **Column content**: `containerName (image:tag) hostPort→containerPort` +- **Scope**: Drill-down only; containers do NOT appear as top-level process entries + +## Architecture + +### New package: `internal/docker/` + +**resolver.go**: +- `ContainerResolver` interface: `Resolve(ctx) → map[int]ContainerInfo, error` +- `ContainerInfo`: `Name`, `Image`, `ID` (short), `Ports []PortMapping` +- `PortMapping`: `HostPort int`, `ContainerPort int`, `Protocol string` +- Impl connects via default Docker socket +- Calls `client.ContainerList(ctx, types.ContainerListOptions{})` for running containers +- Iterates `NetworkSettings.Ports` → builds `hostPort → ContainerInfo` map +- Graceful degradation: Docker unavailable → empty map, no error + +### Model changes (`internal/model/network.go`) + +```go +type ContainerInfo struct { + Name string + Image string + ID string +} + +type PortMapping struct { + HostPort int + ContainerPort int + Protocol string +} +``` + +Add to `Connection`: +```go +Container *ContainerInfo // nil for non-Docker +PortMapping *PortMapping // nil if no mapping found +``` + +### UI changes + +**Detection** (`internal/ui/update.go`): +- `isDockerProcess(name string) bool` — matches known Docker process names +- On drill-down into Docker process, set flag `m.dockerView = true` + +**Messages** (`internal/ui/messages.go`): +- `DockerResolveMsg` — triggers async Docker API call +- `DockerResolvedMsg` — carries `map[int]ContainerInfo` + +**Model** (`internal/ui/model.go`): +- Add `dockerCache map[int]docker.ContainerInfo` +- Add `dockerView bool` (true when viewing Docker process connections) + +**View** (`internal/ui/view_table.go`): +- When `dockerView`, add "Container" column after "State" +- Format: `name (image) hostPort→containerPort` +- Max width ~35 chars, truncate with `...` + +**Sort** (`internal/ui/view_sort.go`): +- Add `SortContainer` to `SortColumn` enum +- Sortable when `dockerView` is true + +### Data flow + +``` +Tick → Collect connections → snapshot + → if Docker process in snapshot: + fire DockerResolveMsg (async cmd) + → DockerResolvedMsg received: + store in m.dockerCache + → View renders: + for each connection, lookup localPort in dockerCache + populate Container column +``` + +Cache refreshed every tick. Single async call, same pattern as NetIO. + +## Files to create/modify + +| File | Action | +|------|--------| +| `internal/docker/resolver.go` | **Create** — ContainerResolver interface + impl | +| `internal/docker/resolver_test.go` | **Create** — tests with mock Docker client | +| `internal/model/network.go` | **Modify** — add ContainerInfo, PortMapping types | +| `internal/ui/model.go` | **Modify** — add dockerCache, dockerView fields | +| `internal/ui/messages.go` | **Modify** — add Docker messages | +| `internal/ui/update.go` | **Modify** — handle Docker messages, detect Docker process | +| `internal/ui/view_table.go` | **Modify** — render Container column | +| `internal/ui/view_sort.go` | **Modify** — add SortContainer | +| `internal/ui/keys.go` | No change (sort mode keys already generic) | +| `go.mod` | **Modify** — add `github.com/docker/docker` dep | + +## Tests + +### `internal/docker/resolver_test.go` (new) + +**Mock strategy**: Define `ContainerResolver` interface; tests use a mock impl (same pattern as `mockCollector` in `internal/ui/mock_collector_test.go`). + +| Test | Description | +|------|-------------| +| `TestResolve_RunningContainers` | Mock returns 2 containers with port bindings → verify port→ContainerInfo map has correct entries | +| `TestResolve_NoContainers` | Mock returns empty list → verify empty map, no error | +| `TestResolve_ContainerWithoutPorts` | Container running but no published ports → excluded from map | +| `TestResolve_MultiplePortsOneContainer` | Container publishes ports 80,443 → both map to same ContainerInfo | +| `TestResolve_OverlappingPorts` | Two containers claim same host port → last-write-wins (document behavior) | +| `TestResolve_DockerUnavailable` | Mock returns connection error → empty map, nil error (graceful degradation) | +| `TestResolve_ContextCancelled` | Cancelled context → returns context error | +| `TestContainerInfo_Format` | `ContainerInfo.Format()` returns `"name (image) hostPort→containerPort"` | +| `TestContainerInfo_FormatTruncation` | Long names truncated to max width with `…` | + +### `internal/ui/update_test.go` (additions) + +| Test | Description | +|------|-------------| +| `TestIsDockerProcess_KnownNames` | `"com.docker.backend"`, `"dockerd"`, `"docker-proxy"`, `"containerd"` → true | +| `TestIsDockerProcess_NonDocker` | `"Chrome"`, `"nginx"`, `"docker-cli"` → false | +| `TestIsDockerProcess_CaseInsensitive` | `"Docker-Proxy"` → true (if we decide case-insensitive) | +| `TestDrillIntoDocker_SetsDockerView` | Enter on Docker process → `m.dockerView == true` | +| `TestDrillIntoNonDocker_DockerViewFalse` | Enter on regular process → `m.dockerView == false` | +| `TestPopFromDockerView_ClearsDockerView` | Esc from Docker connections → `m.dockerView == false` | +| `TestDockerResolvedMsg_PopulatesCache` | Receive `DockerResolvedMsg` with data → `m.dockerCache` populated | +| `TestDockerResolvedMsg_EmptyResult` | Receive empty `DockerResolvedMsg` → cache empty, no error | +| `TestDockerResolvedMsg_ReplacesOldCache` | Second `DockerResolvedMsg` overwrites previous cache | + +### `internal/ui/view_table_test.go` (additions or new) + +| Test | Description | +|------|-------------| +| `TestRenderConnections_DockerView_HasContainerColumn` | Docker view renders "Container" header in table | +| `TestRenderConnections_NonDockerView_NoContainerColumn` | Regular view does NOT render "Container" header | +| `TestRenderConnections_DockerView_MatchedPort` | Connection on port 8080 + cache has port 8080 → shows `"nginx (nginx:latest) 8080→80"` | +| `TestRenderConnections_DockerView_UnmatchedPort` | Connection on port 9999 + cache has no entry → Container column empty | +| `TestRenderConnections_DockerView_EmptyCache` | Docker view with empty cache → all Container columns empty | + +### `internal/ui/view_sort_test.go` (additions) + +| Test | Description | +|------|-------------| +| `TestSortContainer_Ascending` | Sort by Container column ascending → alphabetical by container name | +| `TestSortContainer_Descending` | Sort descending → reverse alphabetical | +| `TestSortContainer_EmptyValues` | Connections without container info sort to bottom (ascending) or top (descending) | +| `TestSortContainer_OnlyInDockerView` | `SortContainer` not in column list for non-Docker views | + +### `internal/model/network_test.go` (additions) + +| Test | Description | +|------|-------------| +| `TestContainerInfo_Struct` | Verify struct fields populate correctly | +| `TestPortMapping_Struct` | Verify PortMapping fields | +| `TestConnection_WithContainer` | Connection with non-nil Container field | +| `TestConnection_WithoutContainer` | Connection with nil Container (backwards compat) | + +### `internal/ui/mock_collector_test.go` (additions) + +| Test | Description | +|------|-------------| +| `mockDockerResolver` | New mock implementing `ContainerResolver` interface — returns configurable `map[int]ContainerInfo` | + +### Integration-style tests + +| Test | Description | +|------|-------------| +| `TestDockerDrillDownFlow` | Full flow: create model with Docker snapshot → drill in → receive DockerResolvedMsg → verify view renders container column with data | +| `TestDockerDrillDownFlow_NoDocker` | Same flow but resolver returns empty → container column shows empty values, no errors | + +## Open questions + +None — all design decisions resolved. diff --git a/go.mod b/go.mod index b579a1c..b7acebb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/docker/docker v28.5.2+incompatible github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.39.0 @@ -13,7 +14,9 @@ require ( ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.4 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect @@ -21,7 +24,16 @@ require ( github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -29,9 +41,16 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect @@ -40,6 +59,14 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 21a451d..c5af7cb 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,25 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= @@ -28,102 +28,161 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw= github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/docker/resolver.go b/internal/docker/resolver.go new file mode 100644 index 0000000..0694d6c --- /dev/null +++ b/internal/docker/resolver.go @@ -0,0 +1,124 @@ +package docker + +import ( + "context" + "fmt" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/kostyay/netmon/internal/model" +) + +// Resolver resolves host ports to Docker container info. +type Resolver interface { + Resolve(ctx context.Context) (map[int]*ContainerPort, error) +} + +// ContainerPort maps a host port to its container and internal port. +type ContainerPort struct { + Container model.ContainerInfo + HostPort int + ContainerPort int + Protocol string +} + +// dockerResolver implements Resolver using the Docker Engine API. +type dockerResolver struct { + newClient func() (dockerAPI, error) +} + +// dockerAPI is the subset of Docker client we need (for testing). +type dockerAPI interface { + ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) + Close() error +} + +// NewResolver creates a Resolver that talks to the Docker daemon. +func NewResolver() Resolver { + return &dockerResolver{ + newClient: func() (dockerAPI, error) { + return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + }, + } +} + +// Resolve queries Docker for running containers and builds a host-port → container map. +// Returns empty map (not error) if Docker is unavailable. +func (r *dockerResolver) Resolve(ctx context.Context) (map[int]*ContainerPort, error) { + cli, err := r.newClient() + if err != nil { + return map[int]*ContainerPort{}, nil // graceful degradation + } + defer func() { _ = cli.Close() }() + + containers, err := cli.ContainerList(ctx, container.ListOptions{}) + if err != nil { + // Context cancellation is a real error + if ctx.Err() != nil { + return nil, ctx.Err() + } + return map[int]*ContainerPort{}, nil // Docker unavailable + } + + result := make(map[int]*ContainerPort) + for _, c := range containers { + ci := model.ContainerInfo{ + Name: cleanContainerName(c.Names), + Image: c.Image, + ID: shortID(c.ID), + } + for _, p := range c.Ports { + if p.PublicPort == 0 { + continue // no host binding + } + result[int(p.PublicPort)] = &ContainerPort{ + Container: ci, + HostPort: int(p.PublicPort), + ContainerPort: int(p.PrivatePort), + Protocol: p.Type, + } + } + } + return result, nil +} + +// cleanContainerName strips the leading "/" from Docker container names. +func cleanContainerName(names []string) string { + if len(names) == 0 { + return "" + } + return strings.TrimPrefix(names[0], "/") +} + +// shortID returns the first 12 chars of a container ID. +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + +// IsDockerProcess returns true if the process name is a known Docker daemon process. +func IsDockerProcess(name string) bool { + lower := strings.ToLower(name) + switch lower { + case "com.docker.backend", "dockerd", "docker-proxy", "containerd", + "docker", "com.docker.vpnkit", "vpnkit-bridge": + return true + } + return false +} + +// FormatColumn formats a ContainerPort for display in the Container column. +func FormatColumn(cp *ContainerPort, maxWidth int) string { + if cp == nil { + return "" + } + s := fmt.Sprintf("%s (%s) %d→%d", cp.Container.Name, cp.Container.Image, cp.HostPort, cp.ContainerPort) + runes := []rune(s) + if maxWidth > 0 && len(runes) > maxWidth { + return string(runes[:maxWidth-1]) + "…" + } + return s +} diff --git a/internal/docker/resolver_test.go b/internal/docker/resolver_test.go new file mode 100644 index 0000000..3fd8924 --- /dev/null +++ b/internal/docker/resolver_test.go @@ -0,0 +1,362 @@ +package docker + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/kostyay/netmon/internal/model" +) + +// mockDockerAPI implements dockerAPI for testing. +type mockDockerAPI struct { + containers []container.Summary + err error +} + +func (m *mockDockerAPI) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) { + if m.err != nil { + return nil, m.err + } + return m.containers, nil +} + +func (m *mockDockerAPI) Close() error { return nil } + +func newTestResolver(mock *mockDockerAPI) *dockerResolver { + return &dockerResolver{ + newClient: func() (dockerAPI, error) { + return mock, nil + }, + } +} + +func newFailingResolver(err error) *dockerResolver { + return &dockerResolver{ + newClient: func() (dockerAPI, error) { + return nil, err + }, + } +} + +func TestResolve_RunningContainers(t *testing.T) { + mock := &mockDockerAPI{ + containers: []container.Summary{ + { + ID: "abc123def456789012", + Names: []string{"/nginx-proxy"}, + Image: "nginx:latest", + Ports: []container.Port{ + {PublicPort: 8080, PrivatePort: 80, Type: "tcp"}, + {PublicPort: 8443, PrivatePort: 443, Type: "tcp"}, + }, + }, + { + ID: "def789abc012345678", + Names: []string{"/redis-cache"}, + Image: "redis:7", + Ports: []container.Port{ + {PublicPort: 6379, PrivatePort: 6379, Type: "tcp"}, + }, + }, + }, + } + + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Fatalf("expected 3 port mappings, got %d", len(result)) + } + + cp := result[8080] + if cp == nil { + t.Fatal("expected mapping for port 8080") + } + if cp.Container.Name != "nginx-proxy" { + t.Errorf("Name = %q, want 'nginx-proxy'", cp.Container.Name) + } + if cp.Container.Image != "nginx:latest" { + t.Errorf("Image = %q, want 'nginx:latest'", cp.Container.Image) + } + if cp.ContainerPort != 80 { + t.Errorf("ContainerPort = %d, want 80", cp.ContainerPort) + } + if cp.HostPort != 8080 { + t.Errorf("HostPort = %d, want 8080", cp.HostPort) + } + + cp6379 := result[6379] + if cp6379 == nil { + t.Fatal("expected mapping for port 6379") + } + if cp6379.Container.Name != "redis-cache" { + t.Errorf("Name = %q, want 'redis-cache'", cp6379.Container.Name) + } +} + +func TestResolve_NoContainers(t *testing.T) { + mock := &mockDockerAPI{containers: []container.Summary{}} + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map, got %d entries", len(result)) + } +} + +func TestResolve_ContainerWithoutPorts(t *testing.T) { + mock := &mockDockerAPI{ + containers: []container.Summary{ + { + ID: "abc123def456", + Names: []string{"/worker"}, + Image: "myapp:latest", + Ports: []container.Port{}, // no published ports + }, + }, + } + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map for container without ports, got %d", len(result)) + } +} + +func TestResolve_ContainerWithUnpublishedPort(t *testing.T) { + mock := &mockDockerAPI{ + containers: []container.Summary{ + { + ID: "abc123def456", + Names: []string{"/worker"}, + Image: "myapp:latest", + Ports: []container.Port{ + {PublicPort: 0, PrivatePort: 3000, Type: "tcp"}, // exposed but not published + }, + }, + }, + } + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map for unpublished port, got %d", len(result)) + } +} + +func TestResolve_MultiplePortsOneContainer(t *testing.T) { + mock := &mockDockerAPI{ + containers: []container.Summary{ + { + ID: "abc123def456", + Names: []string{"/web"}, + Image: "nginx:latest", + Ports: []container.Port{ + {PublicPort: 80, PrivatePort: 80, Type: "tcp"}, + {PublicPort: 443, PrivatePort: 443, Type: "tcp"}, + {PublicPort: 8080, PrivatePort: 80, Type: "tcp"}, + }, + }, + }, + } + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Fatalf("expected 3 mappings, got %d", len(result)) + } + for _, port := range []int{80, 443, 8080} { + if result[port] == nil { + t.Errorf("missing mapping for port %d", port) + } + if result[port] != nil && result[port].Container.Name != "web" { + t.Errorf("port %d: Name = %q, want 'web'", port, result[port].Container.Name) + } + } +} + +func TestResolve_OverlappingPorts(t *testing.T) { + mock := &mockDockerAPI{ + containers: []container.Summary{ + { + ID: "first111111111111", + Names: []string{"/first"}, + Image: "app1:latest", + Ports: []container.Port{{PublicPort: 8080, PrivatePort: 80, Type: "tcp"}}, + }, + { + ID: "second2222222222", + Names: []string{"/second"}, + Image: "app2:latest", + Ports: []container.Port{{PublicPort: 8080, PrivatePort: 3000, Type: "tcp"}}, + }, + }, + } + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Last-write-wins + cp := result[8080] + if cp == nil { + t.Fatal("expected mapping for port 8080") + } + if cp.Container.Name != "second" { + t.Errorf("Name = %q, want 'second' (last-write-wins)", cp.Container.Name) + } +} + +func TestResolve_DockerUnavailable(t *testing.T) { + mock := &mockDockerAPI{err: errors.New("connection refused")} + r := newTestResolver(mock) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("expected nil error for unavailable Docker, got: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map, got %d entries", len(result)) + } +} + +func TestResolve_ClientCreationFails(t *testing.T) { + r := newFailingResolver(errors.New("no docker socket")) + result, err := r.Resolve(context.Background()) + if err != nil { + t.Fatalf("expected nil error for client creation failure, got: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map, got %d entries", len(result)) + } +} + +func TestResolve_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + mock := &mockDockerAPI{err: ctx.Err()} + r := newTestResolver(mock) + _, err := r.Resolve(ctx) + if err == nil { + t.Error("expected error for cancelled context") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got: %v", err) + } +} + +// Tests for helper functions + +func TestCleanContainerName(t *testing.T) { + tests := []struct { + names []string + want string + }{ + {[]string{"/nginx-proxy"}, "nginx-proxy"}, + {[]string{"/web"}, "web"}, + {[]string{"no-slash"}, "no-slash"}, + {[]string{}, ""}, + {nil, ""}, + } + for _, tt := range tests { + got := cleanContainerName(tt.names) + if got != tt.want { + t.Errorf("cleanContainerName(%v) = %q, want %q", tt.names, got, tt.want) + } + } +} + +func TestShortID(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"abc123def456789012345678", "abc123def456"}, + {"short", "short"}, + {"exactly12ch", "exactly12ch"}, + {"", ""}, + } + for _, tt := range tests { + got := shortID(tt.id) + if got != tt.want { + t.Errorf("shortID(%q) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestIsDockerProcess(t *testing.T) { + dockerNames := []string{ + "com.docker.backend", "dockerd", "docker-proxy", + "containerd", "docker", "com.docker.vpnkit", "vpnkit-bridge", + } + for _, name := range dockerNames { + if !IsDockerProcess(name) { + t.Errorf("IsDockerProcess(%q) = false, want true", name) + } + } + + nonDockerNames := []string{ + "Chrome", "nginx", "docker-cli", "mydockertool", + "containerd-shim", "Firefox", "", + } + for _, name := range nonDockerNames { + if IsDockerProcess(name) { + t.Errorf("IsDockerProcess(%q) = true, want false", name) + } + } +} + +func TestIsDockerProcess_CaseInsensitive(t *testing.T) { + if !IsDockerProcess("Docker-Proxy") { + t.Error("IsDockerProcess should be case-insensitive") + } + if !IsDockerProcess("DOCKERD") { + t.Error("IsDockerProcess should be case-insensitive") + } +} + +func TestFormatColumn(t *testing.T) { + cp := &ContainerPort{ + Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest", ID: "abc123"}, + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + } + got := FormatColumn(cp, 0) + want := "nginx (nginx:latest) 8080→80" + if got != want { + t.Errorf("FormatColumn = %q, want %q", got, want) + } +} + +func TestFormatColumn_Nil(t *testing.T) { + got := FormatColumn(nil, 0) + if got != "" { + t.Errorf("FormatColumn(nil) = %q, want empty", got) + } +} + +func TestFormatColumn_Truncation(t *testing.T) { + cp := &ContainerPort{ + Container: model.ContainerInfo{Name: "very-long-container", Image: "registry.io/org/image:v1.2.3"}, + HostPort: 8080, + ContainerPort: 80, + } + got := FormatColumn(cp, 25) + runes := []rune(got) + if len(runes) > 25 { + t.Errorf("rune len = %d, want <= 25", len(runes)) + } +} diff --git a/internal/model/network.go b/internal/model/network.go index 345fc93..b0aa31a 100644 --- a/internal/model/network.go +++ b/internal/model/network.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "sort" "strconv" "strings" @@ -34,13 +35,48 @@ const ( StateNone ConnectionState = "-" ) +// ContainerInfo holds Docker container metadata for a connection. +type ContainerInfo struct { + Name string // Container name (e.g., "nginx-proxy") + Image string // Image tag (e.g., "nginx:latest") + ID string // Short container ID +} + +// PortMapping represents a Docker container port binding. +type PortMapping struct { + HostPort int // Port on the host (e.g., 8080) + ContainerPort int // Port inside the container (e.g., 80) + Protocol string // "tcp" or "udp" +} + +// FormatContainerColumn returns display string for the Container column. +// Format: "name (image) hostPort→containerPort". Truncates to maxWidth runes with "…". +func FormatContainerColumn(ci *ContainerInfo, pm *PortMapping, maxWidth int) string { + if ci == nil { + return "" + } + var s string + if pm != nil { + s = fmt.Sprintf("%s (%s) %d→%d", ci.Name, ci.Image, pm.HostPort, pm.ContainerPort) + } else { + s = fmt.Sprintf("%s (%s)", ci.Name, ci.Image) + } + runes := []rune(s) + if maxWidth > 0 && len(runes) > maxWidth { + return string(runes[:maxWidth-1]) + "…" + } + return s +} + // Connection represents a single network connection. type Connection struct { - PID int32 // Process ID owning this connection - Protocol Protocol // TCP or UDP - LocalAddr string // e.g., 127.0.0.1:52341 - RemoteAddr string // e.g., 142.250.80.46:443 or * for listening - State ConnectionState // e.g., ESTABLISHED, LISTEN, - for UDP + PID int32 // Process ID owning this connection + Protocol Protocol // TCP or UDP + LocalAddr string // e.g., 127.0.0.1:52341 + RemoteAddr string // e.g., 142.250.80.46:443 or * for listening + State ConnectionState // e.g., ESTABLISHED, LISTEN, - for UDP + Container *ContainerInfo // Docker container info (nil for non-Docker) + PortMapping *PortMapping // Docker port mapping (nil if no mapping) } // Application represents a grouped set of connections by app name. diff --git a/internal/model/network_test.go b/internal/model/network_test.go index 8536dd0..9360d93 100644 --- a/internal/model/network_test.go +++ b/internal/model/network_test.go @@ -1,6 +1,7 @@ package model import ( + "strings" "testing" "time" ) @@ -390,3 +391,134 @@ func TestConnectionStateConstants(t *testing.T) { t.Errorf("StateNone = %q, want '-'", StateNone) } } + +// Tests for ContainerInfo and PortMapping + +func TestContainerInfo_Struct(t *testing.T) { + ci := ContainerInfo{ + Name: "nginx-proxy", + Image: "nginx:latest", + ID: "abc123", + } + if ci.Name != "nginx-proxy" { + t.Errorf("Name = %q, want 'nginx-proxy'", ci.Name) + } + if ci.Image != "nginx:latest" { + t.Errorf("Image = %q, want 'nginx:latest'", ci.Image) + } + if ci.ID != "abc123" { + t.Errorf("ID = %q, want 'abc123'", ci.ID) + } +} + +func TestPortMapping_Struct(t *testing.T) { + pm := PortMapping{ + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + } + if pm.HostPort != 8080 { + t.Errorf("HostPort = %d, want 8080", pm.HostPort) + } + if pm.ContainerPort != 80 { + t.Errorf("ContainerPort = %d, want 80", pm.ContainerPort) + } + if pm.Protocol != "tcp" { + t.Errorf("Protocol = %q, want 'tcp'", pm.Protocol) + } +} + +func TestConnection_WithContainer(t *testing.T) { + conn := Connection{ + PID: 100, + Protocol: ProtocolTCP, + LocalAddr: "0.0.0.0:8080", + State: StateListen, + Container: &ContainerInfo{ + Name: "web", + Image: "nginx:latest", + ID: "abc123", + }, + PortMapping: &PortMapping{ + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + }, + } + if conn.Container == nil { + t.Fatal("Container should not be nil") + } + if conn.Container.Name != "web" { + t.Errorf("Container.Name = %q, want 'web'", conn.Container.Name) + } + if conn.PortMapping == nil { + t.Fatal("PortMapping should not be nil") + } + if conn.PortMapping.ContainerPort != 80 { + t.Errorf("PortMapping.ContainerPort = %d, want 80", conn.PortMapping.ContainerPort) + } +} + +func TestConnection_WithoutContainer(t *testing.T) { + conn := Connection{ + PID: 200, + Protocol: ProtocolTCP, + LocalAddr: "127.0.0.1:443", + State: StateEstablished, + } + if conn.Container != nil { + t.Error("Container should be nil for non-Docker connection") + } + if conn.PortMapping != nil { + t.Error("PortMapping should be nil for non-Docker connection") + } +} + +func TestFormatContainerColumn_WithMapping(t *testing.T) { + ci := &ContainerInfo{Name: "nginx", Image: "nginx:latest", ID: "abc"} + pm := &PortMapping{HostPort: 8080, ContainerPort: 80, Protocol: "tcp"} + got := FormatContainerColumn(ci, pm, 0) + want := "nginx (nginx:latest) 8080→80" + if got != want { + t.Errorf("FormatContainerColumn = %q, want %q", got, want) + } +} + +func TestFormatContainerColumn_WithoutMapping(t *testing.T) { + ci := &ContainerInfo{Name: "redis", Image: "redis:7", ID: "def"} + got := FormatContainerColumn(ci, nil, 0) + want := "redis (redis:7)" + if got != want { + t.Errorf("FormatContainerColumn = %q, want %q", got, want) + } +} + +func TestFormatContainerColumn_NilContainer(t *testing.T) { + got := FormatContainerColumn(nil, nil, 0) + if got != "" { + t.Errorf("FormatContainerColumn(nil) = %q, want empty", got) + } +} + +func TestFormatContainerColumn_Truncation(t *testing.T) { + ci := &ContainerInfo{Name: "very-long-container-name", Image: "my-registry.io/org/image:v1.2.3", ID: "abc"} + pm := &PortMapping{HostPort: 8080, ContainerPort: 80, Protocol: "tcp"} + got := FormatContainerColumn(ci, pm, 20) + runes := []rune(got) + if len(runes) > 20 { + t.Errorf("rune len = %d, want <= 20", len(runes)) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("should end with ellipsis, got %q", got) + } +} + +func TestFormatContainerColumn_NoTruncationNeeded(t *testing.T) { + ci := &ContainerInfo{Name: "web", Image: "nginx", ID: "a"} + pm := &PortMapping{HostPort: 80, ContainerPort: 80, Protocol: "tcp"} + got := FormatContainerColumn(ci, pm, 50) + want := "web (nginx) 80→80" + if got != want { + t.Errorf("FormatContainerColumn = %q, want %q", got, want) + } +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index a37dceb..5872234 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -3,6 +3,7 @@ package ui import ( "time" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -34,5 +35,11 @@ type VersionCheckMsg struct { Err error // nil on success (even if up-to-date) } +// DockerResolvedMsg contains Docker container resolution results. +type DockerResolvedMsg struct { + Containers map[int]*docker.ContainerPort // host port → container info + Err error +} + // AnimationTickMsg is sent for UI animation updates (e.g., live indicator pulse). type AnimationTickMsg time.Time diff --git a/internal/ui/mock_collector_test.go b/internal/ui/mock_collector_test.go index 88e1c9f..87cfe12 100644 --- a/internal/ui/mock_collector_test.go +++ b/internal/ui/mock_collector_test.go @@ -3,6 +3,7 @@ package ui import ( "context" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -35,3 +36,18 @@ func (m *mockNetIOCollector) Collect(ctx context.Context) (map[int32]*model.NetI func newMockNetIOCollector(stats map[int32]*model.NetIOStats) *mockNetIOCollector { return &mockNetIOCollector{stats: stats} } + +// mockDockerResolver is a test double for docker.Resolver. +type mockDockerResolver struct { + containers map[int]*docker.ContainerPort + err error +} + +func (m *mockDockerResolver) Resolve(ctx context.Context) (map[int]*docker.ContainerPort, error) { + return m.containers, m.err +} + +// newMockDockerResolver creates a mockDockerResolver with the given containers. +func newMockDockerResolver(containers map[int]*docker.ContainerPort) *mockDockerResolver { + return &mockDockerResolver{containers: containers} +} diff --git a/internal/ui/model.go b/internal/ui/model.go index fbf5f58..4f64541 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kostyay/netmon/internal/collector" "github.com/kostyay/netmon/internal/config" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -60,6 +61,8 @@ const ( SortListen SortTX SortRX + // Docker-specific columns + SortContainer ) // String returns a human-readable name for the SortColumn. @@ -87,6 +90,8 @@ func (s SortColumn) String() string { return "TX" case SortRX: return "RX" + case SortContainer: + return "Container" default: return fmt.Sprintf("SortColumn(%d)", s) } @@ -177,6 +182,11 @@ type Model struct { // Animation state animations bool // whether animations are enabled animationFrame int // current animation frame (for pulsing indicators) + + // Docker container resolution + dockerResolver docker.Resolver // resolves host ports to containers + dockerCache map[int]*docker.ContainerPort // host port → container info + dockerView bool // true when viewing Docker process connections } // killTargetInfo holds info about the process to be killed. @@ -202,6 +212,8 @@ func NewModel() Model { dnsEnabled: config.CurrentSettings.DNSEnabled, serviceNames: config.CurrentSettings.ServiceNames, animations: config.CurrentSettings.Animations, + dockerResolver: docker.NewResolver(), + dockerCache: make(map[int]*docker.ContainerPort), stack: []ViewState{{ Level: LevelProcessList, ProcessName: "", diff --git a/internal/ui/update.go b/internal/ui/update.go index 702059d..7e107c0 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kostyay/netmon/internal/config" "github.com/kostyay/netmon/internal/dns" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" "github.com/kostyay/netmon/internal/release" ) @@ -282,6 +283,7 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { // Clear filter when drilling down (different search context) m.activeFilter = "" m.searchQuery = "" + m.dockerView = docker.IsDockerProcess(app.Name) m.PushView(ViewState{ Level: LevelConnections, ProcessName: app.Name, @@ -291,6 +293,10 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { SortAscending: true, SelectedColumn: SortLocal, }) + // Fire Docker resolution if drilling into Docker process + if m.dockerView { + return m, m.fetchDockerContainers() + } } } return m, nil @@ -305,6 +311,7 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Pop view (go back) m.PopView() + m.dockerView = false return m, nil } @@ -438,11 +445,16 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pruneExpiredChanges(3 * time.Second) // Schedule next tick and fetch new data - return m, tea.Batch( + cmds := []tea.Cmd{ m.tickCmd(), m.fetchData(), m.fetchNetIO(), - ) + } + // Refresh Docker container info when in Docker view + if m.dockerView { + cmds = append(cmds, m.fetchDockerContainers()) + } + return m, tea.Batch(cmds...) case DataMsg: if msg.Err != nil { @@ -498,6 +510,13 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dnsCache[msg.IP] = msg.Hostname return m, nil + case DockerResolvedMsg: + if msg.Err != nil { + return m, nil // Silently ignore Docker errors + } + m.dockerCache = msg.Containers + return m, nil + case VersionCheckMsg: if msg.Err == nil && msg.LatestVersion != "" { m.updateAvailable = msg.LatestVersion @@ -548,6 +567,19 @@ func (m Model) fetchNetIO() tea.Cmd { } } +func (m Model) fetchDockerContainers() tea.Cmd { + if m.dockerResolver == nil { + return nil + } + resolver := m.dockerResolver + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + containers, err := resolver.Resolve(ctx) + return DockerResolvedMsg{Containers: containers, Err: err} + } +} + func (m Model) checkVersion() tea.Cmd { return func() tea.Msg { latest, err := release.CheckLatest("kostyay", "netmon", m.version) @@ -647,7 +679,11 @@ func (m Model) columnsForLevel(level ViewLevel) []SortColumn { case LevelProcessList: cols = processListColumns() case LevelConnections: - cols = connectionsColumns() + if m.dockerView { + cols = dockerConnectionsColumns() + } else { + cols = connectionsColumns() + } case LevelAllConnections: cols = allConnectionsColumns() default: diff --git a/internal/ui/update_test.go b/internal/ui/update_test.go index 447219d..4c10b9e 100644 --- a/internal/ui/update_test.go +++ b/internal/ui/update_test.go @@ -2,10 +2,13 @@ package ui import ( "errors" + "strings" "testing" "time" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -29,6 +32,8 @@ func createTestModel() Model { snapshot: snapshot, netIOCache: make(map[int32]*model.NetIOStats), changes: make(map[ConnectionKey]Change), + dockerResolver: newMockDockerResolver(nil), + dockerCache: make(map[int]*docker.ContainerPort), stack: []ViewState{{ Level: LevelProcessList, ProcessName: "", @@ -1440,3 +1445,293 @@ func TestDNSResolvedMsg_OverwritesCache(t *testing.T) { t.Errorf("dnsCache[8.8.8.8] = %q, want 'new.name'", newModel.dnsCache["8.8.8.8"]) } } + +// Tests for Docker detection and messages + +func createDockerTestModel() Model { + snapshot := &model.NetworkSnapshot{ + Applications: []model.Application{ + {Name: "com.docker.backend", PIDs: []int32{100}, Connections: []model.Connection{ + {PID: 100, Protocol: "TCP", LocalAddr: "0.0.0.0:8080", State: "LISTEN"}, + {PID: 100, Protocol: "TCP", LocalAddr: "0.0.0.0:3306", State: "LISTEN"}, + }}, + {Name: "Chrome", PIDs: []int32{200}, Connections: []model.Connection{ + {PID: 200, Protocol: "TCP", LocalAddr: "127.0.0.1:52341", RemoteAddr: "142.250.80.46:443", State: "ESTABLISHED"}, + }}, + }, + Timestamp: time.Now(), + } + m := Model{ + collector: newMockCollector(snapshot), + netIOCollector: newMockNetIOCollector(nil), + refreshInterval: DefaultRefreshInterval, + snapshot: snapshot, + netIOCache: make(map[int32]*model.NetIOStats), + changes: make(map[ConnectionKey]Change), + dockerResolver: newMockDockerResolver(nil), + dockerCache: make(map[int]*docker.ContainerPort), + stack: []ViewState{{ + Level: LevelProcessList, + ProcessName: "", + Cursor: 0, + SortColumn: SortProcess, + SortAscending: true, + SelectedColumn: SortProcess, + }}, + } + return m +} + +func TestDrillIntoDocker_SetsDockerView(t *testing.T) { + m := createDockerTestModel() + // Chrome sorts before com.docker.backend; Docker is at cursor=1 + m.CurrentView().Cursor = 1 + + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, cmd := m.Update(msg) + newModel := updated.(Model) + + if !newModel.dockerView { + t.Error("dockerView should be true after drilling into Docker process") + } + if newModel.CurrentView().Level != LevelConnections { + t.Errorf("level = %v, want LevelConnections", newModel.CurrentView().Level) + } + // Should fire a Docker resolve command + if cmd == nil { + t.Error("cmd should not be nil (should fire Docker resolve)") + } +} + +func TestDrillIntoNonDocker_DockerViewFalse(t *testing.T) { + m := createDockerTestModel() + // Chrome is first in sorted list + m.CurrentView().Cursor = 0 + + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := m.Update(msg) + newModel := updated.(Model) + + if newModel.dockerView { + t.Error("dockerView should be false after drilling into non-Docker process") + } + if newModel.CurrentView().ProcessName != "Chrome" { + t.Errorf("ProcessName = %q, want 'Chrome'", newModel.CurrentView().ProcessName) + } +} + +func TestPopFromDockerView_ClearsDockerView(t *testing.T) { + m := createDockerTestModel() + m.dockerView = true + m.PushView(ViewState{ + Level: LevelConnections, + ProcessName: "com.docker.backend", + Cursor: 0, + }) + + msg := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ := m.Update(msg) + newModel := updated.(Model) + + if newModel.dockerView { + t.Error("dockerView should be false after popping from Docker view") + } + if newModel.CurrentView().Level != LevelProcessList { + t.Errorf("level = %v, want LevelProcessList", newModel.CurrentView().Level) + } +} + +func TestDockerResolvedMsg_PopulatesCache(t *testing.T) { + m := createDockerTestModel() + containers := map[int]*docker.ContainerPort{ + 8080: { + Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest", ID: "abc123"}, + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + }, + } + msg := DockerResolvedMsg{Containers: containers, Err: nil} + + updated, cmd := m.Update(msg) + newModel := updated.(Model) + + if len(newModel.dockerCache) != 1 { + t.Fatalf("dockerCache length = %d, want 1", len(newModel.dockerCache)) + } + cp := newModel.dockerCache[8080] + if cp == nil { + t.Fatal("expected cache entry for port 8080") + } + if cp.Container.Name != "nginx" { + t.Errorf("Name = %q, want 'nginx'", cp.Container.Name) + } + if cmd != nil { + t.Error("cmd should be nil") + } +} + +func TestDockerResolvedMsg_EmptyResult(t *testing.T) { + m := createDockerTestModel() + msg := DockerResolvedMsg{Containers: map[int]*docker.ContainerPort{}, Err: nil} + + updated, _ := m.Update(msg) + newModel := updated.(Model) + + if len(newModel.dockerCache) != 0 { + t.Errorf("dockerCache length = %d, want 0", len(newModel.dockerCache)) + } +} + +func TestDockerResolvedMsg_ReplacesOldCache(t *testing.T) { + m := createDockerTestModel() + m.dockerCache[8080] = &docker.ContainerPort{ + Container: model.ContainerInfo{Name: "old-container"}, + HostPort: 8080, + } + + newContainers := map[int]*docker.ContainerPort{ + 9090: { + Container: model.ContainerInfo{Name: "new-container", Image: "app:v2", ID: "def456"}, + HostPort: 9090, + ContainerPort: 3000, + Protocol: "tcp", + }, + } + msg := DockerResolvedMsg{Containers: newContainers, Err: nil} + + updated, _ := m.Update(msg) + newModel := updated.(Model) + + // Old cache entry should be gone (full replacement) + if newModel.dockerCache[8080] != nil { + t.Error("old cache entry for port 8080 should be gone") + } + if newModel.dockerCache[9090] == nil { + t.Fatal("expected new cache entry for port 9090") + } + if newModel.dockerCache[9090].Container.Name != "new-container" { + t.Errorf("Name = %q, want 'new-container'", newModel.dockerCache[9090].Container.Name) + } +} + +func TestDockerResolvedMsg_Error(t *testing.T) { + m := createDockerTestModel() + m.dockerCache[8080] = &docker.ContainerPort{ + Container: model.ContainerInfo{Name: "existing"}, + HostPort: 8080, + } + + msg := DockerResolvedMsg{Containers: nil, Err: errors.New("docker error")} + + updated, _ := m.Update(msg) + newModel := updated.(Model) + + // Cache should remain unchanged on error + if newModel.dockerCache[8080] == nil || newModel.dockerCache[8080].Container.Name != "existing" { + t.Error("dockerCache should be unchanged on error") + } +} + +// Integration tests: full Docker drill-down flow + +func TestDockerDrillDownFlow(t *testing.T) { + // Start at process list, drill into Docker process, receive resolver data, verify view + m := createDockerTestModel() + m.width = 120 + m.height = 40 + m.ready = true + m.viewport = viewport.New(116, 30) + + // Step 1: Drill into Docker process (cursor=1, com.docker.backend) + m.CurrentView().Cursor = 1 + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + + if !m.dockerView { + t.Fatal("dockerView should be true after drill-down") + } + if m.CurrentView().Level != LevelConnections { + t.Fatalf("level = %v, want LevelConnections", m.CurrentView().Level) + } + if cmd == nil { + t.Fatal("should fire Docker resolve command") + } + + // Step 2: Simulate DockerResolvedMsg with container data + dockerData := map[int]*docker.ContainerPort{ + 8080: { + Container: model.ContainerInfo{Name: "web", Image: "nginx:latest", ID: "abc123"}, + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + }, + 3306: { + Container: model.ContainerInfo{Name: "db", Image: "mysql:8", ID: "def456"}, + HostPort: 3306, + ContainerPort: 3306, + Protocol: "tcp", + }, + } + updated, _ = m.Update(DockerResolvedMsg{Containers: dockerData}) + m = updated.(Model) + + if len(m.dockerCache) != 2 { + t.Fatalf("dockerCache len = %d, want 2", len(m.dockerCache)) + } + + // Step 3: Verify view renders Container column + view := m.View() + if !strings.Contains(view, "Container") { + t.Error("view should contain Container header") + } + if !strings.Contains(view, "web") { + t.Error("view should contain container name 'web'") + } + if !strings.Contains(view, "db") { + t.Error("view should contain container name 'db'") + } + + // Step 4: Go back clears docker state + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + + if m.dockerView { + t.Error("dockerView should be false after going back") + } + if m.CurrentView().Level != LevelProcessList { + t.Error("should be back at process list") + } +} + +func TestDockerDrillDownFlow_NoDocker(t *testing.T) { + // Docker resolver returns empty: container column present but empty + m := createDockerTestModel() + m.width = 120 + m.height = 40 + m.ready = true + m.viewport = viewport.New(116, 30) + + // Drill into Docker process + m.CurrentView().Cursor = 1 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + + if !m.dockerView { + t.Fatal("dockerView should be true") + } + + // Resolver returns empty (Docker not running) + updated, _ = m.Update(DockerResolvedMsg{Containers: map[int]*docker.ContainerPort{}}) + m = updated.(Model) + + if len(m.dockerCache) != 0 { + t.Fatalf("dockerCache should be empty, got %d entries", len(m.dockerCache)) + } + + // View should still render Container column header, just no container names + view := m.View() + if !strings.Contains(view, "Container") { + t.Error("view should still contain Container header even with empty cache") + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go index abac2ff..71a5510 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -192,7 +192,7 @@ func (m Model) renderFrozenHeader() string { b.WriteString("\n") // Table header - columns := connectionsColumns() + columns := m.activeConnectionsColumns() widths := calculateColumnWidths(columns, m.contentWidth()) b.WriteString(m.renderConnectionsHeader(widths)) @@ -695,7 +695,7 @@ func (m Model) renderConnectionsList() string { // === CONNECTIONS TABLE === // Calculate column widths - columns := connectionsColumns() + columns := m.activeConnectionsColumns() widths := calculateColumnWidths(columns, m.contentWidth()) // Header @@ -715,12 +715,24 @@ func (m Model) renderConnectionsList() string { proto := string(conn.Protocol) remoteAddr := formatRemoteAddr(conn.RemoteAddr, proto, m.dnsCache, m.serviceNames) localAddr := formatAddr(conn.LocalAddr, proto, m.serviceNames) - row := fmt.Sprintf("%-*s %-*s %-*s %-*s", - widths[0], conn.Protocol, - widths[1], truncateAddr(localAddr, widths[1]), - widths[2], truncateAddr(remoteAddr, widths[2]), - widths[3], conn.State, - ) + var row string + if m.dockerView { + containerCol := containerColumnValue(conn, m.dockerCache, widths[4]) + row = fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", + widths[0], conn.Protocol, + widths[1], truncateAddr(localAddr, widths[1]), + widths[2], truncateAddr(remoteAddr, widths[2]), + widths[3], conn.State, + widths[4], containerCol, + ) + } else { + row = fmt.Sprintf("%-*s %-*s %-*s %-*s", + widths[0], conn.Protocol, + widths[1], truncateAddr(localAddr, widths[1]), + widths[2], truncateAddr(remoteAddr, widths[2]), + widths[3], conn.State, + ) + } change := m.GetChange(conn) b.WriteString(renderRowWithHighlight(row, isSelected, change)) @@ -735,10 +747,18 @@ func (m Model) renderConnectionsHeader(widths []int) string { if view == nil { return "" } - columns := connectionsColumns() + columns := m.activeConnectionsColumns() return renderTableHeader(columns, widths, view.SelectedColumn, view.SortColumn, view.SortAscending, true) } +// activeConnectionsColumns returns the right column set for the current connections view. +func (m Model) activeConnectionsColumns() []columnDef { + if m.dockerView { + return dockerConnectionsColumns() + } + return connectionsColumns() +} + // connectionWithProcess holds a connection along with its process name for the all-connections view. type connectionWithProcess struct { model.Connection @@ -900,7 +920,7 @@ func (m Model) renderConnectionsListData() string { } var b strings.Builder - columns := connectionsColumns() + columns := m.activeConnectionsColumns() widths := calculateColumnWidths(columns, m.contentWidth()) conns = m.sortConnectionsForView(conns) cursorIdx := view.Cursor @@ -910,12 +930,24 @@ func (m Model) renderConnectionsListData() string { proto := string(conn.Protocol) remoteAddr := formatRemoteAddr(conn.RemoteAddr, proto, m.dnsCache, m.serviceNames) localAddr := formatAddr(conn.LocalAddr, proto, m.serviceNames) - row := fmt.Sprintf("%-*s %-*s %-*s %-*s", - widths[0], conn.Protocol, - widths[1], truncateAddr(localAddr, widths[1]), - widths[2], truncateAddr(remoteAddr, widths[2]), - widths[3], conn.State, - ) + var row string + if m.dockerView { + containerCol := containerColumnValue(conn, m.dockerCache, widths[4]) + row = fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", + widths[0], conn.Protocol, + widths[1], truncateAddr(localAddr, widths[1]), + widths[2], truncateAddr(remoteAddr, widths[2]), + widths[3], conn.State, + widths[4], containerCol, + ) + } else { + row = fmt.Sprintf("%-*s %-*s %-*s %-*s", + widths[0], conn.Protocol, + widths[1], truncateAddr(localAddr, widths[1]), + widths[2], truncateAddr(remoteAddr, widths[2]), + widths[3], conn.State, + ) + } change := m.GetChange(conn) b.WriteString(renderRowWithHighlight(row, isSelected, change)) } diff --git a/internal/ui/view_sort.go b/internal/ui/view_sort.go index 48f0d3b..d3768a5 100644 --- a/internal/ui/view_sort.go +++ b/internal/ui/view_sort.go @@ -3,6 +3,7 @@ package ui import ( "sort" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -166,6 +167,20 @@ func (m Model) getAggregatedBytes(pids []int32, isSent bool) uint64 { return total } +// containerSortKey returns a sort key for a connection's container column. +// Empty string for connections without a matching container (sorts to top ascending). +func (m Model) containerSortKey(conn model.Connection) string { + port := model.ExtractPort(conn.LocalAddr) + if port == 0 { + return "" + } + cp, ok := m.dockerCache[port] + if !ok || cp == nil { + return "" + } + return docker.FormatColumn(cp, 0) +} + // sortConnectionsForView sorts connections based on current view state. // Uses (local addr, remote addr) as secondary keys for stable ordering. func (m Model) sortConnectionsForView(conns []model.Connection) []model.Connection { @@ -190,6 +205,8 @@ func (m Model) sortConnectionsForView(conns []model.Connection) []model.Connecti cmp = compareString(sorted[i].RemoteAddr, sorted[j].RemoteAddr) case SortState: cmp = compareString(string(sorted[i].State), string(sorted[j].State)) + case SortContainer: + cmp = compareString(m.containerSortKey(sorted[i]), m.containerSortKey(sorted[j])) default: cmp = compareString(sorted[i].LocalAddr, sorted[j].LocalAddr) } diff --git a/internal/ui/view_sort_test.go b/internal/ui/view_sort_test.go index 71e1e8d..9560533 100644 --- a/internal/ui/view_sort_test.go +++ b/internal/ui/view_sort_test.go @@ -3,6 +3,7 @@ package ui import ( "testing" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -899,3 +900,144 @@ func TestSortAllConnections_DoesNotModifyOriginal(t *testing.T) { t.Error("sortAllConnections should not modify original slice") } } + +// Test sortConnectionsForView by Container column + +func TestSortConnectionsForView_ByContainer_Ascending(t *testing.T) { + m := Model{ + stack: []ViewState{{ + Level: LevelConnections, + SortColumn: SortContainer, + SortAscending: true, + }}, + dockerCache: map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, HostPort: 8080, ContainerPort: 80}, + 3000: {Container: model.ContainerInfo{Name: "app", Image: "node:18"}, HostPort: 3000, ContainerPort: 3000}, + }, + } + conns := []model.Connection{ + {LocalAddr: "0.0.0.0:8080"}, + {LocalAddr: "0.0.0.0:3000"}, + } + + result := m.sortConnectionsForView(conns) + + // "app" < "nginx" alphabetically + if result[0].LocalAddr != "0.0.0.0:3000" || result[1].LocalAddr != "0.0.0.0:8080" { + t.Errorf("Expected [3000(app), 8080(nginx)], got [%s, %s]", + result[0].LocalAddr, result[1].LocalAddr) + } +} + +func TestSortConnectionsForView_ByContainer_Descending(t *testing.T) { + m := Model{ + stack: []ViewState{{ + Level: LevelConnections, + SortColumn: SortContainer, + SortAscending: false, + }}, + dockerCache: map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, HostPort: 8080, ContainerPort: 80}, + 3000: {Container: model.ContainerInfo{Name: "app", Image: "node:18"}, HostPort: 3000, ContainerPort: 3000}, + }, + } + conns := []model.Connection{ + {LocalAddr: "0.0.0.0:3000"}, + {LocalAddr: "0.0.0.0:8080"}, + } + + result := m.sortConnectionsForView(conns) + + // "nginx" > "app" descending + if result[0].LocalAddr != "0.0.0.0:8080" || result[1].LocalAddr != "0.0.0.0:3000" { + t.Errorf("Expected [8080(nginx), 3000(app)], got [%s, %s]", + result[0].LocalAddr, result[1].LocalAddr) + } +} + +func TestSortConnectionsForView_ByContainer_EmptyValues(t *testing.T) { + m := Model{ + stack: []ViewState{{ + Level: LevelConnections, + SortColumn: SortContainer, + SortAscending: true, + }}, + dockerCache: map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, HostPort: 8080, ContainerPort: 80}, + }, + } + conns := []model.Connection{ + {LocalAddr: "0.0.0.0:8080"}, + {LocalAddr: "0.0.0.0:9999"}, // not in docker cache + } + + result := m.sortConnectionsForView(conns) + + // Empty container sorts before "nginx" (ascending, "" < "nginx") + if result[0].LocalAddr != "0.0.0.0:9999" || result[1].LocalAddr != "0.0.0.0:8080" { + t.Errorf("Expected unmatched port first (ascending), got [%s, %s]", + result[0].LocalAddr, result[1].LocalAddr) + } +} + +func TestSortConnectionsForView_ByContainer_NilCache(t *testing.T) { + m := Model{ + stack: []ViewState{{ + Level: LevelConnections, + SortColumn: SortContainer, + SortAscending: true, + }}, + dockerCache: nil, + } + conns := []model.Connection{ + {LocalAddr: "0.0.0.0:8080"}, + {LocalAddr: "0.0.0.0:3000"}, + } + + // Should not panic with nil cache + result := m.sortConnectionsForView(conns) + + if len(result) != 2 { + t.Error("Should return all connections even with nil cache") + } +} + +func TestContainerSortKey_MatchedPort(t *testing.T) { + m := Model{ + dockerCache: map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, HostPort: 8080, ContainerPort: 80}, + }, + } + + key := m.containerSortKey(model.Connection{LocalAddr: "0.0.0.0:8080"}) + + if key == "" { + t.Error("Expected non-empty sort key for matched port") + } +} + +func TestContainerSortKey_UnmatchedPort(t *testing.T) { + m := Model{ + dockerCache: map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, HostPort: 8080, ContainerPort: 80}, + }, + } + + key := m.containerSortKey(model.Connection{LocalAddr: "0.0.0.0:9999"}) + + if key != "" { + t.Errorf("Expected empty sort key for unmatched port, got %q", key) + } +} + +func TestContainerSortKey_NoPort(t *testing.T) { + m := Model{ + dockerCache: map[int]*docker.ContainerPort{}, + } + + key := m.containerSortKey(model.Connection{LocalAddr: ""}) + + if key != "" { + t.Errorf("Expected empty sort key for empty addr, got %q", key) + } +} diff --git a/internal/ui/view_table.go b/internal/ui/view_table.go index 73a4299..face84c 100644 --- a/internal/ui/view_table.go +++ b/internal/ui/view_table.go @@ -2,6 +2,9 @@ package ui import ( "strings" + + "github.com/kostyay/netmon/internal/docker" + "github.com/kostyay/netmon/internal/model" ) // columnDef defines a table column with sizing properties. @@ -160,6 +163,30 @@ func connectionsColumns() []columnDef { } } +// dockerConnectionsColumns returns columns for connections with Docker container info. +func dockerConnectionsColumns() []columnDef { + return []columnDef{ + {label: "Proto", id: SortProtocol, minWidth: 6, flex: 0}, + {label: "Local", id: SortLocal, minWidth: 18, flex: 2}, + {label: "Remote", id: SortRemote, minWidth: 18, flex: 2}, + {label: "State", id: SortState, minWidth: 11, flex: 0}, + {label: "Container", id: SortContainer, minWidth: 15, flex: 3}, + } +} + +// containerColumnValue returns the Container column display string for a connection. +func containerColumnValue(conn model.Connection, cache map[int]*docker.ContainerPort, maxWidth int) string { + port := model.ExtractPort(conn.LocalAddr) + if port == 0 { + return "" + } + cp, ok := cache[port] + if !ok || cp == nil { + return "" + } + return docker.FormatColumn(cp, maxWidth) +} + // allConnectionsColumns returns the column definitions for the all-connections list. func allConnectionsColumns() []columnDef { return []columnDef{ diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 84a1827..610e2f5 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/charmbracelet/bubbles/viewport" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -1713,3 +1714,139 @@ func TestTruncateString_EmptyString(t *testing.T) { t.Errorf("truncateString empty = %q, want ''", result) } } + +// Tests for Docker Container column rendering + +func TestRenderConnections_DockerView_HasContainerColumn(t *testing.T) { + m := createDockerViewTestModel() + result := m.renderConnectionsHeader(calculateColumnWidths(dockerConnectionsColumns(), 120)) + if !strings.Contains(result, "Container") { + t.Error("Docker view header should contain 'Container' column") + } +} + +func TestRenderConnections_NonDockerView_NoContainerColumn(t *testing.T) { + m := createDockerViewTestModel() + m.dockerView = false + result := m.renderConnectionsHeader(calculateColumnWidths(connectionsColumns(), 120)) + if strings.Contains(result, "Container") { + t.Error("Non-Docker view header should NOT contain 'Container' column") + } +} + +func TestRenderConnections_DockerView_MatchedPort(t *testing.T) { + m := createDockerViewTestModel() + m.dockerCache[8080] = &docker.ContainerPort{ + Container: model.ContainerInfo{Name: "nginx", Image: "nginx:latest", ID: "abc123"}, + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + } + // Render should include container info + result := m.renderConnectionsListData() + if !strings.Contains(result, "nginx") { + t.Error("Docker view should show container name for matched port") + } + if !strings.Contains(result, "nginx:latest") { + t.Error("Docker view should show container image for matched port") + } +} + +func TestRenderConnections_DockerView_UnmatchedPort(t *testing.T) { + m := createDockerViewTestModel() + // Cache has port 9999, but connections are on 8080 and 3306 + m.dockerCache[9999] = &docker.ContainerPort{ + Container: model.ContainerInfo{Name: "other", Image: "other:latest"}, + HostPort: 9999, + ContainerPort: 80, + } + result := m.renderConnectionsListData() + if strings.Contains(result, "other") { + t.Error("Docker view should NOT show unmatched container") + } +} + +func TestRenderConnections_DockerView_EmptyCache(t *testing.T) { + m := createDockerViewTestModel() + // Empty cache — no containers resolved + result := m.renderConnectionsListData() + // Should still render without errors + if result == "" { + t.Error("Docker view should render even with empty cache") + } + // Container column data should be empty strings with no container names + _ = strings.Contains(result, "Container") +} + +func TestContainerColumnValue_MatchedPort(t *testing.T) { + conn := model.Connection{LocalAddr: "0.0.0.0:8080", Protocol: "TCP"} + cache := map[int]*docker.ContainerPort{ + 8080: { + Container: model.ContainerInfo{Name: "web", Image: "nginx:1.25"}, + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + }, + } + got := containerColumnValue(conn, cache, 0) + if !strings.Contains(got, "web") || !strings.Contains(got, "nginx:1.25") { + t.Errorf("containerColumnValue = %q, want to contain 'web' and 'nginx:1.25'", got) + } +} + +func TestContainerColumnValue_UnmatchedPort(t *testing.T) { + conn := model.Connection{LocalAddr: "0.0.0.0:9090", Protocol: "TCP"} + cache := map[int]*docker.ContainerPort{ + 8080: {Container: model.ContainerInfo{Name: "web"}}, + } + got := containerColumnValue(conn, cache, 0) + if got != "" { + t.Errorf("containerColumnValue for unmatched port = %q, want empty", got) + } +} + +func TestContainerColumnValue_NilCache(t *testing.T) { + conn := model.Connection{LocalAddr: "0.0.0.0:8080", Protocol: "TCP"} + got := containerColumnValue(conn, nil, 0) + if got != "" { + t.Errorf("containerColumnValue with nil cache = %q, want empty", got) + } +} + +func createDockerViewTestModel() Model { + snapshot := &model.NetworkSnapshot{ + Applications: []model.Application{ + { + Name: "com.docker.backend", + PIDs: []int32{100}, + Connections: []model.Connection{ + {PID: 100, Protocol: "TCP", LocalAddr: "0.0.0.0:8080", State: "LISTEN"}, + {PID: 100, Protocol: "TCP", LocalAddr: "0.0.0.0:3306", State: "LISTEN"}, + }, + }, + }, + Timestamp: time.Now(), + } + return Model{ + collector: newMockCollector(snapshot), + netIOCollector: newMockNetIOCollector(nil), + snapshot: snapshot, + netIOCache: make(map[int32]*model.NetIOStats), + changes: make(map[ConnectionKey]Change), + dockerResolver: newMockDockerResolver(nil), + dockerCache: make(map[int]*docker.ContainerPort), + dockerView: true, + width: 120, + height: 40, + ready: true, + viewport: viewport.New(116, 30), + stack: []ViewState{{ + Level: LevelConnections, + ProcessName: "com.docker.backend", + Cursor: 0, + SortColumn: SortLocal, + SortAscending: true, + SelectedColumn: SortLocal, + }}, + } +} From 2785ed6302abc2d572cc501bba34b5f5018a8196 Mon Sep 17 00:00:00 2001 From: kostyay Date: Wed, 11 Feb 2026 23:17:39 +0100 Subject: [PATCH 2/3] docs: add CHANGELOG.md with docker container column entry Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b69ab39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## feat/docker-container-column + +Drilling into Docker processes now reveals which containers own each connection (#12). +A new "Container" column appears showing container name, image, and port mapping +(e.g., `nginx (nginx:latest) 8080→80`) — powered by the Docker Engine API with +async resolution and port-keyed caching. The column is fully sortable and degrades +gracefully when Docker is unavailable. New `internal/docker` package with `Resolver` +interface and comprehensive test coverage across model, resolver, UI, and integration layers. From 4a1428ea69f9960a8f84924a9b7918a03c456788 Mon Sep 17 00:00:00 2001 From: kostyay Date: Wed, 11 Feb 2026 23:22:01 +0100 Subject: [PATCH 3/3] fix: bump Go to 1.25.7 to resolve crypto/tls vulnerabilities Fixes GO-2026-4340 (TLS encryption level) and GO-2026-4337 (TLS session resumption). govulncheck now reports 0 vulnerabilities. Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 +- internal/ui/model.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b7acebb..7e711f0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kostyay/netmon -go 1.25.5 +go 1.25.7 require ( github.com/charmbracelet/bubbles v0.21.0 diff --git a/internal/ui/model.go b/internal/ui/model.go index 4f64541..610622a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -184,7 +184,7 @@ type Model struct { animationFrame int // current animation frame (for pulsing indicators) // Docker container resolution - dockerResolver docker.Resolver // resolves host ports to containers + dockerResolver docker.Resolver // resolves host ports to containers dockerCache map[int]*docker.ContainerPort // host port → container info dockerView bool // true when viewing Docker process connections }