Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
178 changes: 178 additions & 0 deletions docs/plans/2026-02-11-docker-container-column-design.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 28 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,37 +1,56 @@
module github.com/kostyay/netmon

go 1.25.5
go 1.25.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
gopkg.in/yaml.v3 v3.0.1
)

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
github.com/charmbracelet/x/term v0.2.2 // indirect
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
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
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
Expand All @@ -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
)
Loading