diff --git a/docs/plans/2026-02-11-docker-container-rows-design.md b/docs/plans/2026-02-11-docker-container-rows-design.md new file mode 100644 index 0000000..86b0f17 --- /dev/null +++ b/docs/plans/2026-02-11-docker-container-rows-design.md @@ -0,0 +1,77 @@ +# Docker Container Virtual Rows + +## Problem +Docker connections appear under generic daemon processes (com.docker.backend, docker-proxy). Users must drill into these to see which container owns which port. Too many clicks. + +## Solution +Add virtual container rows to the process list. Each running Docker container with published ports gets its own row, showing port bindings at a glance. + +## Setting +- `DockerContainers` (bool, default: `true`) in `config/Settings` +- Toggle via Settings modal (`S` key) +- Persisted to `~/.config/netmon/settings.yaml` + +## Data Model + +New type in `model/network.go`: +```go +type VirtualContainer struct { + Info ContainerInfo + PortMappings []PortMapping +} +``` + +`NetworkSnapshot` gains `VirtualContainers []VirtualContainer`. + +Docker resolver expanded: in addition to `map[int]*ContainerPort`, return `[]VirtualContainer` listing all containers with published ports. + +## Process List Display + +Virtual rows appended after real processes when setting is on. + +| Column | Value | +|--------|-------| +| PID | Short container ID (12 chars) | +| Process | `🐳 name (image)` | +| Conns | Count of connections matching host ports | +| ESTAB | Counted from matched connections | +| LISTEN | Counted from matched connections | +| TX | `β€”` (unavailable) | +| RX | `β€”` (unavailable) | + +## Drill-Down + +Enter on virtual row pushes `LevelConnections`: +- Collects connections from all Docker daemon apps where `ExtractPort(localAddr)` matches container's host port bindings +- Shows Docker columns (Proto, Local, Remote, State, Container) +- Header: container name, image, ID, port mappings + +## Kill / Stop + +`x` on virtual row: `docker stop` (10s timeout) with confirmation. +`X` on virtual row: `docker kill` with confirmation. + +New functions in `internal/docker/`: +- `StopContainer(ctx, containerID, timeout)` +- `KillContainer(ctx, containerID)` + +## Search/Filter + +Virtual rows match on: container name, image, container ID. + +## Sort + +Virtual rows sort alongside real processes normally. Container ID sorts lexicographically in PID column. + +## Implementation Order + +1. `config/settings.go` β€” add `DockerContainers` field +2. `model/network.go` β€” add `VirtualContainer`, expand `NetworkSnapshot` +3. `docker/resolver.go` β€” return `[]VirtualContainer` alongside port map +4. `docker/actions.go` β€” `StopContainer`, `KillContainer` +5. `ui/model.go` β€” store virtual containers, add container setting +6. `ui/view_table.go` β€” no changes (reuse existing columns) +7. `ui/view.go` β€” render virtual rows in process list, handle drill-down header +8. `ui/update.go` β€” handle drill-down into virtual row, kill/stop logic +9. `ui/view.go` (settings modal) β€” add DockerContainers toggle +10. Tests for each layer diff --git a/internal/config/settings.go b/internal/config/settings.go index c4014f6..d2a22d6 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -12,7 +12,8 @@ type Settings struct { DNSEnabled bool `yaml:"dnsEnabled"` ServiceNames bool `yaml:"serviceNames"` HighlightChanges bool `yaml:"highlightChanges"` - Animations bool `yaml:"animations"` // Enable UI animations (live pulse, spinners) + Animations bool `yaml:"animations"` // Enable UI animations (live pulse, spinners) + DockerContainers bool `yaml:"dockerContainers"` // Show Docker containers as virtual rows } // DefaultSettings returns the default settings. @@ -22,6 +23,7 @@ func DefaultSettings() *Settings { ServiceNames: true, // On by default (no overhead) HighlightChanges: true, // On by default Animations: true, // On by default + DockerContainers: true, // On by default } } diff --git a/internal/docker/actions.go b/internal/docker/actions.go new file mode 100644 index 0000000..6f61db7 --- /dev/null +++ b/internal/docker/actions.go @@ -0,0 +1,40 @@ +package docker + +import ( + "context" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +// newDockerClient creates a Docker client configured from environment. +func newDockerClient() (*client.Client, error) { + return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) +} + +// StopContainer sends a stop signal to a Docker container. +// Timeout is the seconds to wait before force-killing. +func StopContainer(ctx context.Context, containerID string, timeoutSecs int) error { + cli, err := newDockerClient() + if err != nil { + return err + } + defer func() { _ = cli.Close() }() + + opts := container.StopOptions{} + if timeoutSecs > 0 { + opts.Timeout = &timeoutSecs + } + return cli.ContainerStop(ctx, containerID, opts) +} + +// KillContainer sends SIGKILL to a Docker container. +func KillContainer(ctx context.Context, containerID string) error { + cli, err := newDockerClient() + if err != nil { + return err + } + defer func() { _ = cli.Close() }() + + return cli.ContainerKill(ctx, containerID, "SIGKILL") +} diff --git a/internal/docker/actions_test.go b/internal/docker/actions_test.go new file mode 100644 index 0000000..1df74c2 --- /dev/null +++ b/internal/docker/actions_test.go @@ -0,0 +1,13 @@ +package docker + +import ( + "testing" +) + +func TestStopContainer_ClientCreationDoesNotPanic(t *testing.T) { + // Verify StopContainer doesn't panic even without Docker daemon. + // Real Docker client creation will fail gracefully. + // We can't easily mock the client here, but we verify the function signature works. + _ = StopContainer + _ = KillContainer +} diff --git a/internal/docker/resolver.go b/internal/docker/resolver.go index 0694d6c..1be876c 100644 --- a/internal/docker/resolver.go +++ b/internal/docker/resolver.go @@ -10,9 +10,15 @@ import ( "github.com/kostyay/netmon/internal/model" ) +// ResolveResult holds both port mappings and virtual containers from a Docker query. +type ResolveResult struct { + Ports map[int]*ContainerPort + Containers []model.VirtualContainer +} + // Resolver resolves host ports to Docker container info. type Resolver interface { - Resolve(ctx context.Context) (map[int]*ContainerPort, error) + Resolve(ctx context.Context) (*ResolveResult, error) } // ContainerPort maps a host port to its container and internal port. @@ -43,44 +49,60 @@ func NewResolver() Resolver { } } -// 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) { +// Resolve queries Docker for running containers and builds port mappings + virtual container rows. +// Returns empty result (not error) if Docker is unavailable. +func (r *dockerResolver) Resolve(ctx context.Context) (*ResolveResult, error) { + emptyResult := &ResolveResult{Ports: map[int]*ContainerPort{}} + cli, err := r.newClient() if err != nil { - return map[int]*ContainerPort{}, nil // graceful degradation + return emptyResult, 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 + return emptyResult, nil // Docker unavailable } - result := make(map[int]*ContainerPort) + portMap := make(map[int]*ContainerPort) + var vcs []model.VirtualContainer + for _, c := range containers { ci := model.ContainerInfo{ Name: cleanContainerName(c.Names), Image: c.Image, ID: shortID(c.ID), } + + var mappings []model.PortMapping for _, p := range c.Ports { if p.PublicPort == 0 { - continue // no host binding + continue } - result[int(p.PublicPort)] = &ContainerPort{ + portMap[int(p.PublicPort)] = &ContainerPort{ Container: ci, HostPort: int(p.PublicPort), ContainerPort: int(p.PrivatePort), Protocol: p.Type, } + mappings = append(mappings, model.PortMapping{ + HostPort: int(p.PublicPort), + ContainerPort: int(p.PrivatePort), + Protocol: p.Type, + }) } + + vcs = append(vcs, model.VirtualContainer{ + Info: ci, + PortMappings: mappings, + }) } - return result, nil + + return &ResolveResult{Ports: portMap, Containers: vcs}, nil } // cleanContainerName strips the leading "/" from Docker container names. diff --git a/internal/docker/resolver_test.go b/internal/docker/resolver_test.go index 3fd8924..5fb737b 100644 --- a/internal/docker/resolver_test.go +++ b/internal/docker/resolver_test.go @@ -68,11 +68,11 @@ func TestResolve_RunningContainers(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result) != 3 { - t.Fatalf("expected 3 port mappings, got %d", len(result)) + if len(result.Ports) != 3 { + t.Fatalf("expected 3 port mappings, got %d", len(result.Ports)) } - cp := result[8080] + cp := result.Ports[8080] if cp == nil { t.Fatal("expected mapping for port 8080") } @@ -89,13 +89,27 @@ func TestResolve_RunningContainers(t *testing.T) { t.Errorf("HostPort = %d, want 8080", cp.HostPort) } - cp6379 := result[6379] + cp6379 := result.Ports[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) } + + // Verify virtual containers + if len(result.Containers) != 2 { + t.Fatalf("expected 2 virtual containers, got %d", len(result.Containers)) + } + if result.Containers[0].Info.Name != "nginx-proxy" { + t.Errorf("Container[0].Name = %q, want 'nginx-proxy'", result.Containers[0].Info.Name) + } + if len(result.Containers[0].PortMappings) != 2 { + t.Errorf("nginx-proxy should have 2 port mappings, got %d", len(result.Containers[0].PortMappings)) + } + if result.Containers[1].Info.Name != "redis-cache" { + t.Errorf("Container[1].Name = %q, want 'redis-cache'", result.Containers[1].Info.Name) + } } func TestResolve_NoContainers(t *testing.T) { @@ -105,8 +119,11 @@ func TestResolve_NoContainers(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result) != 0 { - t.Errorf("expected empty map, got %d entries", len(result)) + if len(result.Ports) != 0 { + t.Errorf("expected empty map, got %d entries", len(result.Ports)) + } + if len(result.Containers) != 0 { + t.Errorf("expected no virtual containers, got %d", len(result.Containers)) } } @@ -126,8 +143,12 @@ func TestResolve_ContainerWithoutPorts(t *testing.T) { 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)) + if len(result.Ports) != 0 { + t.Errorf("expected empty map for container without ports, got %d", len(result.Ports)) + } + // Container still appears as virtual row (even without port mappings) + if len(result.Containers) != 1 { + t.Errorf("expected 1 virtual container, got %d", len(result.Containers)) } } @@ -149,8 +170,8 @@ func TestResolve_ContainerWithUnpublishedPort(t *testing.T) { 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)) + if len(result.Ports) != 0 { + t.Errorf("expected empty map for unpublished port, got %d", len(result.Ports)) } } @@ -174,17 +195,24 @@ func TestResolve_MultiplePortsOneContainer(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result) != 3 { - t.Fatalf("expected 3 mappings, got %d", len(result)) + if len(result.Ports) != 3 { + t.Fatalf("expected 3 mappings, got %d", len(result.Ports)) } for _, port := range []int{80, 443, 8080} { - if result[port] == nil { + if result.Ports[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) + if result.Ports[port] != nil && result.Ports[port].Container.Name != "web" { + t.Errorf("port %d: Name = %q, want 'web'", port, result.Ports[port].Container.Name) } } + // Single container β†’ 1 virtual container with 3 mappings + if len(result.Containers) != 1 { + t.Fatalf("expected 1 virtual container, got %d", len(result.Containers)) + } + if len(result.Containers[0].PortMappings) != 3 { + t.Errorf("expected 3 port mappings, got %d", len(result.Containers[0].PortMappings)) + } } func TestResolve_OverlappingPorts(t *testing.T) { @@ -210,7 +238,7 @@ func TestResolve_OverlappingPorts(t *testing.T) { t.Fatalf("unexpected error: %v", err) } // Last-write-wins - cp := result[8080] + cp := result.Ports[8080] if cp == nil { t.Fatal("expected mapping for port 8080") } @@ -226,8 +254,8 @@ func TestResolve_DockerUnavailable(t *testing.T) { 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)) + if len(result.Ports) != 0 { + t.Errorf("expected empty map, got %d entries", len(result.Ports)) } } @@ -237,8 +265,8 @@ func TestResolve_ClientCreationFails(t *testing.T) { 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)) + if len(result.Ports) != 0 { + t.Errorf("expected empty map, got %d entries", len(result.Ports)) } } diff --git a/internal/model/network.go b/internal/model/network.go index b0aa31a..a17939e 100644 --- a/internal/model/network.go +++ b/internal/model/network.go @@ -49,6 +49,12 @@ type PortMapping struct { Protocol string // "tcp" or "udp" } +// VirtualContainer represents a Docker container as a virtual process row. +type VirtualContainer struct { + Info ContainerInfo + PortMappings []PortMapping +} + // 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 { diff --git a/internal/ui/docker_containers_test.go b/internal/ui/docker_containers_test.go new file mode 100644 index 0000000..e6db18e --- /dev/null +++ b/internal/ui/docker_containers_test.go @@ -0,0 +1,427 @@ +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kostyay/netmon/internal/config" + "github.com/kostyay/netmon/internal/docker" + "github.com/kostyay/netmon/internal/model" +) + +// testModelWithDockerContainers creates a Model pre-loaded with snapshot and virtual containers. +func testModelWithDockerContainers() Model { + snapshot := &model.NetworkSnapshot{ + Applications: []model.Application{ + { + Name: "com.docker.backend", + PIDs: []int32{100}, + Connections: []model.Connection{ + {PID: 100, Protocol: model.ProtocolTCP, LocalAddr: "0.0.0.0:8080", RemoteAddr: "*:*", State: model.StateListen}, + {PID: 100, Protocol: model.ProtocolTCP, LocalAddr: "0.0.0.0:3000", RemoteAddr: "1.2.3.4:5678", State: model.StateEstablished}, + }, + EstablishedCount: 1, + ListenCount: 1, + }, + { + Name: "Chrome", + PIDs: []int32{200}, + Connections: []model.Connection{ + {PID: 200, Protocol: model.ProtocolTCP, LocalAddr: "127.0.0.1:52341", RemoteAddr: "142.250.80.46:443", State: model.StateEstablished}, + }, + EstablishedCount: 1, + }, + }, + } + + vcs := []model.VirtualContainer{ + { + Info: model.ContainerInfo{Name: "nginx-proxy", Image: "nginx:latest", ID: "abc123def456"}, + PortMappings: []model.PortMapping{ + {HostPort: 8080, ContainerPort: 80, Protocol: "tcp"}, + }, + }, + { + Info: model.ContainerInfo{Name: "redis-cache", Image: "redis:7", ID: "def789abc012"}, + PortMappings: []model.PortMapping{ + {HostPort: 3000, ContainerPort: 6379, Protocol: "tcp"}, + }, + }, + } + + m := Model{ + collector: newMockCollector(snapshot), + netIOCollector: newMockNetIOCollector(nil), + refreshInterval: DefaultRefreshInterval, + netIOCache: make(map[int32]*model.NetIOStats), + changes: make(map[ConnectionKey]Change), + dnsCache: make(map[string]string), + dockerResolver: newMockDockerResolver(nil), + dockerCache: make(map[int]*docker.ContainerPort), + dockerContainers: true, + virtualContainers: vcs, + snapshot: snapshot, + width: 120, + height: 40, + stack: []ViewState{{ + Level: LevelProcessList, + SortColumn: SortProcess, + SortAscending: true, + SelectedColumn: SortProcess, + }}, + } + return m +} + +func TestDockerContainers_SettingDefaultTrue(t *testing.T) { + s := config.DefaultSettings() + if !s.DockerContainers { + t.Error("DockerContainers should default to true") + } +} + +func TestDockerContainers_NewModelInitFromSettings(t *testing.T) { + orig := config.CurrentSettings.DockerContainers + defer func() { config.CurrentSettings.DockerContainers = orig }() + + config.CurrentSettings.DockerContainers = false + m := NewModel() + if m.dockerContainers { + t.Error("dockerContainers should be false when setting is false") + } + + config.CurrentSettings.DockerContainers = true + m = NewModel() + if !m.dockerContainers { + t.Error("dockerContainers should be true when setting is true") + } +} + +func TestContainerDisplayName(t *testing.T) { + vc := model.VirtualContainer{ + Info: model.ContainerInfo{Name: "nginx", Image: "nginx:latest"}, + } + got := containerDisplayName(vc) + want := "🐳 nginx (nginx:latest)" + if got != want { + t.Errorf("containerDisplayName = %q, want %q", got, want) + } +} + +func TestIsVirtualContainerName(t *testing.T) { + if !isVirtualContainerName("🐳 nginx (nginx:latest)") { + t.Error("should detect virtual container name") + } + if isVirtualContainerName("Chrome") { + t.Error("should not detect regular process name") + } + if isVirtualContainerName("") { + t.Error("should not detect empty string") + } +} + +func TestFilteredVirtualContainers_WhenEnabled(t *testing.T) { + m := testModelWithDockerContainers() + + vcs := m.filteredVirtualContainers() + if len(vcs) != 2 { + t.Fatalf("expected 2 virtual containers, got %d", len(vcs)) + } +} + +func TestFilteredVirtualContainers_WhenDisabled(t *testing.T) { + m := testModelWithDockerContainers() + m.dockerContainers = false + + vcs := m.filteredVirtualContainers() + if len(vcs) != 0 { + t.Errorf("expected 0 virtual containers when disabled, got %d", len(vcs)) + } +} + +func TestFilteredVirtualContainers_WithFilter(t *testing.T) { + m := testModelWithDockerContainers() + m.activeFilter = "nginx" + + vcs := m.filteredVirtualContainers() + if len(vcs) != 1 { + t.Fatalf("expected 1 filtered virtual container, got %d", len(vcs)) + } + if vcs[0].Info.Name != "nginx-proxy" { + t.Errorf("filtered container = %q, want 'nginx-proxy'", vcs[0].Info.Name) + } +} + +func TestFilteredVirtualContainers_FilterByImage(t *testing.T) { + m := testModelWithDockerContainers() + m.activeFilter = "redis:7" + + vcs := m.filteredVirtualContainers() + if len(vcs) != 1 { + t.Fatalf("expected 1 filtered virtual container, got %d", len(vcs)) + } + if vcs[0].Info.Name != "redis-cache" { + t.Errorf("filtered container = %q, want 'redis-cache'", vcs[0].Info.Name) + } +} + +func TestFilteredVirtualContainers_FilterByID(t *testing.T) { + m := testModelWithDockerContainers() + m.activeFilter = "abc123" + + vcs := m.filteredVirtualContainers() + if len(vcs) != 1 { + t.Fatalf("expected 1 filtered virtual container, got %d", len(vcs)) + } + if vcs[0].Info.Name != "nginx-proxy" { + t.Errorf("filtered container = %q, want 'nginx-proxy'", vcs[0].Info.Name) + } +} + +func TestFilteredCount_IncludesVirtualContainers(t *testing.T) { + m := testModelWithDockerContainers() + + count := m.filteredCount() + // 2 real apps + 2 virtual containers = 4 + if count != 4 { + t.Errorf("filteredCount = %d, want 4", count) + } +} + +func TestFilteredCount_VirtualContainersExcludedWhenDisabled(t *testing.T) { + m := testModelWithDockerContainers() + m.dockerContainers = false + + count := m.filteredCount() + // 2 real apps, no virtual containers + if count != 2 { + t.Errorf("filteredCount = %d, want 2", count) + } +} + +func TestVirtualContainerApp_BuildsSyntheticApp(t *testing.T) { + m := testModelWithDockerContainers() + + vc := m.virtualContainers[0] // nginx-proxy, port 8080 + name := containerDisplayName(vc) + app := m.virtualContainerApp(name) + + if app == nil { + t.Fatal("virtualContainerApp returned nil") + } + if app.Name != name { + t.Errorf("Name = %q, want %q", app.Name, name) + } + if app.Exe != "nginx:latest" { + t.Errorf("Exe = %q, want 'nginx:latest'", app.Exe) + } + // Should have the connection on port 8080 from com.docker.backend + if len(app.Connections) != 1 { + t.Fatalf("expected 1 connection, got %d", len(app.Connections)) + } + if app.Connections[0].LocalAddr != "0.0.0.0:8080" { + t.Errorf("LocalAddr = %q, want '0.0.0.0:8080'", app.Connections[0].LocalAddr) + } +} + +func TestVirtualContainerApp_NoMatchReturnsNil(t *testing.T) { + m := testModelWithDockerContainers() + + app := m.virtualContainerApp("🐳 nonexistent (fake:latest)") + if app != nil { + t.Error("expected nil for nonexistent virtual container") + } +} + +func TestFindSelectedApp_RegularProcess(t *testing.T) { + m := testModelWithDockerContainers() + + app := m.findSelectedApp("Chrome") + if app == nil { + t.Fatal("findSelectedApp returned nil for Chrome") + } + if app.Name != "Chrome" { + t.Errorf("Name = %q, want 'Chrome'", app.Name) + } +} + +func TestFindSelectedApp_VirtualContainer(t *testing.T) { + m := testModelWithDockerContainers() + + vc := m.virtualContainers[0] + name := containerDisplayName(vc) + app := m.findSelectedApp(name) + + if app == nil { + t.Fatal("findSelectedApp returned nil for virtual container") + } + if app.Exe != "nginx:latest" { + t.Errorf("Exe = %q, want 'nginx:latest'", app.Exe) + } +} + +func TestFindSelectedApp_NotFound(t *testing.T) { + m := testModelWithDockerContainers() + + app := m.findSelectedApp("NonExistent") + if app != nil { + t.Error("expected nil for non-existent process") + } +} + +func TestDrillDown_VirtualContainer(t *testing.T) { + m := testModelWithDockerContainers() + + // Move cursor to first virtual container (index 2, after 2 real apps) + view := m.CurrentView() + view.Cursor = 2 + + // Press Enter to drill down + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := m.Update(msg) + newModel := updated.(Model) + + // Should have pushed a new view + if len(newModel.stack) != 2 { + t.Fatalf("stack length = %d, want 2", len(newModel.stack)) + } + currentView := newModel.CurrentView() + if currentView.Level != LevelConnections { + t.Errorf("Level = %v, want LevelConnections", currentView.Level) + } + if !isVirtualContainerName(currentView.ProcessName) { + t.Errorf("ProcessName = %q, expected virtual container name", currentView.ProcessName) + } + if !newModel.dockerView { + t.Error("dockerView should be true after drilling into virtual container") + } +} + +func TestSettingsToggle_DockerContainers(t *testing.T) { + m := testModelWithDockerContainers() + m.settingsMode = true + m.settingsCursor = 4 // Docker Containers is index 4 + + // Press Enter/Space to toggle + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := m.Update(msg) + newModel := updated.(Model) + + // Should have toggled off + if newModel.dockerContainers { + t.Error("dockerContainers should be false after toggle") + } + if newModel.virtualContainers != nil { + t.Error("virtualContainers should be nil after disabling") + } +} + +func TestDockerResolvedMsg_StoresVirtualContainers(t *testing.T) { + m := testModelWithDockerContainers() + m.virtualContainers = nil // start empty + + vcs := []model.VirtualContainer{ + {Info: model.ContainerInfo{Name: "test", Image: "test:1", ID: "aaa111bbb222"}}, + } + msg := DockerResolvedMsg{ + Containers: map[int]*docker.ContainerPort{}, + VirtualContainers: vcs, + } + + updated, _ := m.Update(msg) + newModel := updated.(Model) + + if len(newModel.virtualContainers) != 1 { + t.Fatalf("expected 1 virtual container, got %d", len(newModel.virtualContainers)) + } + if newModel.virtualContainers[0].Info.Name != "test" { + t.Errorf("Name = %q, want 'test'", newModel.virtualContainers[0].Info.Name) + } +} + +func TestKillMode_VirtualContainer(t *testing.T) { + m := testModelWithDockerContainers() + + // Move cursor to first virtual container + view := m.CurrentView() + view.Cursor = 2 + + // Press x to enter kill mode + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} + updated, _ := m.Update(msg) + newModel := updated.(Model) + + if !newModel.killMode { + t.Error("should be in kill mode") + } + if newModel.killTarget == nil { + t.Fatal("killTarget should not be nil") + } + if newModel.killTarget.ContainerID == "" { + t.Error("ContainerID should be set for virtual container") + } + if newModel.killTarget.ContainerID != "abc123def456" { + t.Errorf("ContainerID = %q, want 'abc123def456'", newModel.killTarget.ContainerID) + } +} + +func TestKillModalContent_Container(t *testing.T) { + m := testModelWithDockerContainers() + m.killMode = true + m.killTarget = &killTargetInfo{ + ProcessName: "🐳 nginx (nginx:latest)", + Exe: "nginx:latest", + Signal: "SIGTERM", + ContainerID: "abc123def456", + } + + content := m.renderKillModalContent() + if content == "" { + t.Fatal("kill modal content should not be empty") + } + // Should contain container-specific text + if !containsText(content, "Stop this container") { + t.Error("kill modal should say 'Stop this container'") + } + if !containsText(content, "abc123def456") { + t.Error("kill modal should show container ID") + } +} + +func TestRenderProcessListData_IncludesVirtualContainerRows(t *testing.T) { + m := testModelWithDockerContainers() + m.ready = true + + content := m.renderProcessListData() + if content == "" { + t.Fatal("renderProcessListData should not be empty") + } + // Should contain virtual container names + if !containsText(content, "nginx-proxy") { + t.Error("process list should include nginx-proxy virtual container") + } + if !containsText(content, "redis-cache") { + t.Error("process list should include redis-cache virtual container") + } + // Should also contain regular processes + if !containsText(content, "Chrome") { + t.Error("process list should include Chrome") + } +} + +func TestRenderProcessListData_NoVirtualContainersWhenDisabled(t *testing.T) { + m := testModelWithDockerContainers() + m.dockerContainers = false + m.ready = true + + content := m.renderProcessListData() + if containsText(content, "nginx-proxy") { + t.Error("process list should not include virtual containers when disabled") + } +} + +// containsText checks if content contains the substring after stripping ANSI codes. +func containsText(content, substr string) bool { + return strings.Contains(stripAnsi(content), substr) +} diff --git a/internal/ui/kill.go b/internal/ui/kill.go index f23083e..611022c 100644 --- a/internal/ui/kill.go +++ b/internal/ui/kill.go @@ -1,12 +1,14 @@ package ui import ( + "context" "fmt" "syscall" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" "github.com/kostyay/netmon/internal/process" ) @@ -27,39 +29,54 @@ func (m Model) enterKillMode(signal string) (tea.Model, tea.Cmd) { switch view.Level { case LevelProcessList: apps := m.sortProcessList(m.filteredApps()) - if idx >= len(apps) { - return m, nil - } - app := apps[idx] - if len(app.PIDs) == 0 { - return m, nil - } - target = &killTargetInfo{ - PID: app.PIDs[0], - PIDs: app.PIDs, // Store all PIDs for multi-process apps - ProcessName: app.Name, - Exe: app.Exe, - Signal: signal, - } - - case LevelConnections: - for _, app := range m.snapshot.Applications { - if app.Name != view.ProcessName { - continue - } - conns := m.sortConnectionsForView(m.filteredConnections(app.Connections)) - if idx >= len(conns) { + if idx < len(apps) { + app := apps[idx] + if len(app.PIDs) == 0 { return m, nil } - conn := conns[idx] target = &killTargetInfo{ - PID: conn.PID, + PID: app.PIDs[0], + PIDs: app.PIDs, ProcessName: app.Name, Exe: app.Exe, - Port: model.ExtractPort(conn.LocalAddr), Signal: signal, } - break + } else { + // Virtual container row + vcs := m.filteredVirtualContainers() + vcIdx := idx - len(apps) + if vcIdx < 0 || vcIdx >= len(vcs) { + return m, nil + } + vc := vcs[vcIdx] + target = &killTargetInfo{ + ProcessName: containerDisplayName(vc), + Exe: vc.Info.Image, + Signal: signal, + ContainerID: vc.Info.ID, + } + } + + case LevelConnections: + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp == nil { + return m, nil + } + conns := m.sortConnectionsForView(m.filteredConnections(selectedApp.Connections)) + if idx >= len(conns) { + return m, nil + } + conn := conns[idx] + target = &killTargetInfo{ + PID: conn.PID, + ProcessName: selectedApp.Name, + Exe: selectedApp.Exe, + Port: model.ExtractPort(conn.LocalAddr), + Signal: signal, + } + // If viewing a virtual container, set ContainerID for docker stop + if vc := m.findVirtualContainer(view.ProcessName); vc != nil { + target.ContainerID = vc.Info.ID } case LevelAllConnections: @@ -68,7 +85,6 @@ func (m Model) enterKillMode(signal string) (tea.Model, tea.Cmd) { return m, nil } conn := conns[idx] - // Look up the Exe from the application var exe string for _, app := range m.snapshot.Applications { if app.Name == conn.ProcessName { @@ -94,22 +110,48 @@ func (m Model) enterKillMode(signal string) (tea.Model, tea.Cmd) { return m, nil } -// executeKill sends the signal to the target process(es). +// finishKill sets common fields after kill execution. +func (m *Model) finishKill() { + m.killMode = false + m.killResultAt = time.Now() + m.killTarget = nil +} + +// executeKill sends the signal to the target process(es) or stops a Docker container. func (m Model) executeKill() (tea.Model, tea.Cmd) { if m.killTarget == nil { m.killMode = false return m, nil } + // Docker container stop/kill + if m.killTarget.ContainerID != "" { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var err error + if m.killTarget.Signal == "SIGKILL" { + err = docker.KillContainer(ctx, m.killTarget.ContainerID) + } else { + err = docker.StopContainer(ctx, m.killTarget.ContainerID, 10) + } + if err != nil { + m.killResult = fmt.Sprintf("Failed to stop container %s: %v", m.killTarget.ContainerID, err) + } else { + m.killResult = fmt.Sprintf("Stopped container %s", m.killTarget.ContainerID) + } + m.finishKill() + return m, nil + } + + // Process kill via syscall sig, ok := process.SignalMap[m.killTarget.Signal] if !ok { sig = syscall.SIGTERM } - // If we have multiple PIDs (from process list), kill all of them pidsToKill := m.killTarget.PIDs if len(pidsToKill) == 0 { - // Fall back to single PID for connection-level kills pidsToKill = []int32{m.killTarget.PID} } @@ -124,9 +166,6 @@ func (m Model) executeKill() (tea.Model, tea.Cmd) { } } - m.killMode = false - - // Format result message if failed == 0 { if len(pidsToKill) == 1 { m.killResult = fmt.Sprintf("Killed PID %d (%s)", pidsToKill[0], m.killTarget.ProcessName) @@ -139,8 +178,6 @@ func (m Model) executeKill() (tea.Model, tea.Cmd) { m.killResult = fmt.Sprintf("Killed %d PIDs, %d failed (%s)", killed, failed, m.killTarget.ProcessName) } - m.killResultAt = time.Now() - m.killTarget = nil - + m.finishKill() return m, nil } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 5872234..c3e0d73 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -37,8 +37,9 @@ type VersionCheckMsg struct { // DockerResolvedMsg contains Docker container resolution results. type DockerResolvedMsg struct { - Containers map[int]*docker.ContainerPort // host port β†’ container info - Err error + Containers map[int]*docker.ContainerPort // host port β†’ container info + VirtualContainers []model.VirtualContainer // containers as virtual process rows + Err error } // AnimationTickMsg is sent for UI animation updates (e.g., live indicator pulse). diff --git a/internal/ui/mock_collector_test.go b/internal/ui/mock_collector_test.go index 87cfe12..be267ad 100644 --- a/internal/ui/mock_collector_test.go +++ b/internal/ui/mock_collector_test.go @@ -39,15 +39,18 @@ func newMockNetIOCollector(stats map[int32]*model.NetIOStats) *mockNetIOCollecto // mockDockerResolver is a test double for docker.Resolver. type mockDockerResolver struct { - containers map[int]*docker.ContainerPort - err error + result *docker.ResolveResult + err error } -func (m *mockDockerResolver) Resolve(ctx context.Context) (map[int]*docker.ContainerPort, error) { - return m.containers, m.err +func (m *mockDockerResolver) Resolve(ctx context.Context) (*docker.ResolveResult, error) { + if m.result == nil { + return &docker.ResolveResult{Ports: map[int]*docker.ContainerPort{}}, m.err + } + return m.result, m.err } // newMockDockerResolver creates a mockDockerResolver with the given containers. func newMockDockerResolver(containers map[int]*docker.ContainerPort) *mockDockerResolver { - return &mockDockerResolver{containers: containers} + return &mockDockerResolver{result: &docker.ResolveResult{Ports: containers}} } diff --git a/internal/ui/model.go b/internal/ui/model.go index 610622a..e8a02f6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -184,9 +184,11 @@ type Model struct { 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 + 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 + dockerContainers bool // show virtual container rows in process list + virtualContainers []model.VirtualContainer // cached virtual container rows } // killTargetInfo holds info about the process to be killed. @@ -197,6 +199,7 @@ type killTargetInfo struct { Exe string // executable path Port int // optional, 0 if killing by PID only Signal string // signal to send (default SIGTERM) + ContainerID string // Docker container ID (non-empty β†’ use docker stop/kill) } // NewModel creates a new Model with default settings. @@ -214,6 +217,7 @@ func NewModel() Model { animations: config.CurrentSettings.Animations, dockerResolver: docker.NewResolver(), dockerCache: make(map[int]*docker.ContainerPort), + dockerContainers: config.CurrentSettings.DockerContainers, stack: []ViewState{{ Level: LevelProcessList, ProcessName: "", diff --git a/internal/ui/selection.go b/internal/ui/selection.go index 158a93e..bf7983f 100644 --- a/internal/ui/selection.go +++ b/internal/ui/selection.go @@ -34,17 +34,14 @@ func (m Model) findConnectionIndex(key *model.ConnectionKey) int { switch view.Level { case LevelConnections: - // Find the process and get its connections - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - conns := m.filteredConnections(m.snapshot.Applications[i].Connections) - conns = m.sortConnectionsForView(conns) - for j, conn := range conns { - if m.connectionMatchesKey(conn, key) { - return j - } + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil { + conns := m.filteredConnections(selectedApp.Connections) + conns = m.sortConnectionsForView(conns) + for j, conn := range conns { + if m.connectionMatchesKey(conn, key) { + return j } - break } } case LevelAllConnections: @@ -127,14 +124,12 @@ func (m *Model) validateSelection() { switch view.Level { case LevelProcessList: apps := m.filteredApps() - itemCount = len(apps) + itemCount = len(apps) + len(m.filteredVirtualContainers()) case LevelConnections: - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - conns := m.filteredConnections(m.snapshot.Applications[i].Connections) - itemCount = len(conns) - break - } + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil { + conns := m.filteredConnections(selectedApp.Connections) + itemCount = len(conns) } case LevelAllConnections: itemCount = len(m.filteredAllConnections()) @@ -192,18 +187,22 @@ func (m *Model) updateSelectedIDFromCursor() { apps = m.sortProcessList(apps) if view.Cursor >= 0 && view.Cursor < len(apps) { view.SelectedID = model.SelectionIDFromProcess(apps[view.Cursor].Name) + } else { + vcs := m.filteredVirtualContainers() + vcIdx := view.Cursor - len(apps) + if vcIdx >= 0 && vcIdx < len(vcs) { + view.SelectedID = model.SelectionIDFromProcess(containerDisplayName(vcs[vcIdx])) + } } case LevelConnections: - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - conns := m.filteredConnections(m.snapshot.Applications[i].Connections) - conns = m.sortConnectionsForView(conns) - if view.Cursor >= 0 && view.Cursor < len(conns) { - conn := conns[view.Cursor] - processName := m.getProcessNameByPID(conn.PID) - view.SelectedID = model.SelectionIDFromConnection(processName, conn.LocalAddr, conn.RemoteAddr) - } - break + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil { + conns := m.filteredConnections(selectedApp.Connections) + conns = m.sortConnectionsForView(conns) + if view.Cursor >= 0 && view.Cursor < len(conns) { + conn := conns[view.Cursor] + processName := m.getProcessNameByPID(conn.PID) + view.SelectedID = model.SelectionIDFromConnection(processName, conn.LocalAddr, conn.RemoteAddr) } } case LevelAllConnections: diff --git a/internal/ui/update.go b/internal/ui/update.go index 7e107c0..233c37b 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -29,6 +29,9 @@ func (m Model) Init() tea.Cmd { if m.animations { cmds = append(cmds, m.animationTickCmd()) } + if m.dockerContainers { + cmds = append(cmds, m.fetchDockerContainers()) + } return tea.Batch(cmds...) } @@ -135,37 +138,41 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if matchKey(key, KeyDown, KeyDownAlt) { - maxCursor := 3 // Number of settings - 1 + maxCursor := 4 // Number of settings - 1 if m.settingsCursor < maxCursor { m.settingsCursor++ } return m, nil } if matchKey(key, KeyEnter, KeySpace) { - // Toggle the selected setting + var cmd tea.Cmd switch m.settingsCursor { case 0: // DNS Resolution m.dnsEnabled = !m.dnsEnabled config.CurrentSettings.DNSEnabled = m.dnsEnabled - _ = config.SaveSettings(config.CurrentSettings) case 1: // Service Names m.serviceNames = !m.serviceNames config.CurrentSettings.ServiceNames = m.serviceNames - _ = config.SaveSettings(config.CurrentSettings) case 2: // Highlight Changes m.highlightChanges = !m.highlightChanges config.CurrentSettings.HighlightChanges = m.highlightChanges - _ = config.SaveSettings(config.CurrentSettings) case 3: // Animations m.animations = !m.animations config.CurrentSettings.Animations = m.animations - _ = config.SaveSettings(config.CurrentSettings) - // Start animation tick if enabled if m.animations { - return m, m.animationTickCmd() + cmd = m.animationTickCmd() + } + case 4: // Docker Containers + m.dockerContainers = !m.dockerContainers + config.CurrentSettings.DockerContainers = m.dockerContainers + if m.dockerContainers { + cmd = m.fetchDockerContainers() + } else { + m.virtualContainers = nil } } - return m, nil + _ = config.SaveSettings(config.CurrentSettings) + return m, cmd } return m, nil // Ignore other keys in settings mode } @@ -277,10 +284,9 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { // Not in sort mode - drill down on process list if view.Level == LevelProcessList { apps := m.sortProcessList(m.filteredApps()) - // Use cursor directly for selection + vcs := m.filteredVirtualContainers() if view.Cursor >= 0 && view.Cursor < len(apps) { app := apps[view.Cursor] - // Clear filter when drilling down (different search context) m.activeFilter = "" m.searchQuery = "" m.dockerView = docker.IsDockerProcess(app.Name) @@ -288,15 +294,31 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { Level: LevelConnections, ProcessName: app.Name, Cursor: 0, - SelectedID: model.SelectionID{}, // Start fresh in new view + SelectedID: model.SelectionID{}, SortColumn: SortLocal, SortAscending: true, SelectedColumn: SortLocal, }) - // Fire Docker resolution if drilling into Docker process if m.dockerView { return m, m.fetchDockerContainers() } + } else if vcIdx := view.Cursor - len(apps); vcIdx >= 0 && vcIdx < len(vcs) { + // Drill into virtual container row + vc := vcs[vcIdx] + m.activeFilter = "" + m.searchQuery = "" + m.dockerView = true + vcName := containerDisplayName(vc) + m.PushView(ViewState{ + Level: LevelConnections, + ProcessName: vcName, + Cursor: 0, + SelectedID: model.SelectionID{}, + SortColumn: SortLocal, + SortAscending: true, + SelectedColumn: SortLocal, + }) + return m, m.fetchDockerContainers() } } return m, nil @@ -450,8 +472,8 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.fetchData(), m.fetchNetIO(), } - // Refresh Docker container info when in Docker view - if m.dockerView { + // Refresh Docker container info when in Docker view or containers enabled + if m.dockerView || m.dockerContainers { cmds = append(cmds, m.fetchDockerContainers()) } return m, tea.Batch(cmds...) @@ -515,6 +537,7 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // Silently ignore Docker errors } m.dockerCache = msg.Containers + m.virtualContainers = msg.VirtualContainers return m, nil case VersionCheckMsg: @@ -575,8 +598,15 @@ func (m Model) fetchDockerContainers() tea.Cmd { 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} + result, err := resolver.Resolve(ctx) + if result == nil { + return DockerResolvedMsg{Err: err} + } + return DockerResolvedMsg{ + Containers: result.Ports, + VirtualContainers: result.Containers, + Err: err, + } } } @@ -653,16 +683,15 @@ func (m Model) maxCursorForLevel(level ViewLevel) int { } switch level { case LevelProcessList: - return len(m.snapshot.Applications) + return len(m.snapshot.Applications) + len(m.filteredVirtualContainers()) case LevelConnections: view := m.CurrentView() if view == nil { return 0 } - for _, app := range m.snapshot.Applications { - if app.Name == view.ProcessName { - return len(app.Connections) - } + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil { + return len(selectedApp.Connections) } return 0 case LevelAllConnections: @@ -734,12 +763,11 @@ func (m *Model) filteredCount() int { view := m.CurrentView() switch view.Level { case LevelProcessList: - return len(m.filteredApps()) + return len(m.filteredApps()) + len(m.filteredVirtualContainers()) case LevelConnections: - for _, app := range m.snapshot.Applications { - if app.Name == view.ProcessName { - return len(m.filteredConnections(app.Connections)) - } + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil { + return len(m.filteredConnections(selectedApp.Connections)) } return 0 case LevelAllConnections: diff --git a/internal/ui/view.go b/internal/ui/view.go index 71a5510..32181bd 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/kostyay/netmon/internal/config" + "github.com/kostyay/netmon/internal/docker" "github.com/kostyay/netmon/internal/model" ) @@ -116,13 +117,9 @@ func (m Model) frozenHeaderHeight() int { case LevelConnections: // Process name (1) + [exe (1)] + stats line (1) + blank line (1) + table header (1) lines := 4 - if m.snapshot != nil { - for _, app := range m.snapshot.Applications { - if app.Name == view.ProcessName && app.Exe != "" { - lines = 5 - break - } - } + selectedApp := m.findSelectedApp(view.ProcessName) + if selectedApp != nil && selectedApp.Exe != "" { + lines = 5 } return lines default: @@ -159,14 +156,7 @@ func (m Model) renderFrozenHeader() string { b.WriteString(m.renderProcessListHeader(widths)) case LevelConnections: - // Find the selected process - var selectedApp *model.Application - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - selectedApp = &m.snapshot.Applications[i] - break - } - } + selectedApp := m.findSelectedApp(view.ProcessName) if selectedApp == nil { return "" } @@ -233,7 +223,11 @@ func (m Model) View() string { } if m.killMode && m.killTarget != nil { modalWidth := m.killModalWidth() - return m.overlayDangerModal(baseContent, m.renderKillModalContent(), "Kill Process", modalWidth) + title := "Kill Process" + if m.killTarget.ContainerID != "" { + title = "Stop Container" + } + return m.overlayDangerModal(baseContent, m.renderKillModalContent(), title, modalWidth) } return baseContent @@ -503,6 +497,98 @@ func (m Model) filteredApps() []model.Application { return result } +// isVirtualContainerName returns true if the name is a virtual container row name. +func isVirtualContainerName(name string) bool { + return strings.HasPrefix(name, "🐳 ") +} + +// findVirtualContainer returns the VirtualContainer matching the display name, or nil. +func (m Model) findVirtualContainer(displayName string) *model.VirtualContainer { + for i := range m.virtualContainers { + if containerDisplayName(m.virtualContainers[i]) == displayName { + return &m.virtualContainers[i] + } + } + return nil +} + +// virtualContainerApp builds a synthetic Application for a virtual container. +func (m Model) virtualContainerApp(name string) *model.Application { + vc := m.findVirtualContainer(name) + if vc == nil || m.snapshot == nil { + return nil + } + hostPorts := make(map[int]bool) + for _, pm := range vc.PortMappings { + hostPorts[pm.HostPort] = true + } + app := &model.Application{ + Name: name, + Exe: vc.Info.Image, + } + for _, a := range m.snapshot.Applications { + if !docker.IsDockerProcess(a.Name) { + continue + } + for _, conn := range a.Connections { + port := model.ExtractPort(conn.LocalAddr) + if port > 0 && hostPorts[port] { + app.Connections = append(app.Connections, conn) + switch conn.State { + case model.StateEstablished: + app.EstablishedCount++ + case model.StateListen: + app.ListenCount++ + } + } + } + app.PIDs = append(app.PIDs, a.PIDs...) + } + return app +} + +// containerDisplayName returns the display name for a virtual container row. +func containerDisplayName(vc model.VirtualContainer) string { + return "🐳 " + vc.Info.Name + " (" + vc.Info.Image + ")" +} + +// findSelectedApp finds the application for the current connections view. +func (m Model) findSelectedApp(processName string) *model.Application { + if isVirtualContainerName(processName) { + return m.virtualContainerApp(processName) + } + if m.snapshot == nil { + return nil + } + for i := range m.snapshot.Applications { + if m.snapshot.Applications[i].Name == processName { + return &m.snapshot.Applications[i] + } + } + return nil +} + +// filteredVirtualContainers returns virtual containers matching the current filter. +func (m Model) filteredVirtualContainers() []model.VirtualContainer { + if !m.dockerContainers || len(m.virtualContainers) == 0 { + return nil + } + filter := m.currentFilter() + if filter == "" { + return m.virtualContainers + } + filterLower := strings.ToLower(filter) + var result []model.VirtualContainer + for _, vc := range m.virtualContainers { + if strings.Contains(strings.ToLower(vc.Info.Name), filterLower) || + strings.Contains(strings.ToLower(vc.Info.Image), filterLower) || + strings.Contains(strings.ToLower(vc.Info.ID), filterLower) { + result = append(result, vc) + } + } + return result +} + // filteredConnections returns connections matching the current filter for a specific process. func (m Model) filteredConnections(conns []model.Connection) []model.Connection { filter := m.currentFilter() @@ -646,15 +732,7 @@ func (m Model) renderConnectionsList() string { return "" } - // Find the selected process - var selectedApp *model.Application - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - selectedApp = &m.snapshot.Applications[i] - break - } - } - + selectedApp := m.findSelectedApp(view.ProcessName) if selectedApp == nil { return EmptyStyle().Render("Process not found") } @@ -884,6 +962,32 @@ func (m Model) renderProcessListData() string { b.WriteString(renderRow(row, isSelected)) } + // Append virtual container rows + vcs := m.filteredVirtualContainers() + for i, vc := range vcs { + idx := len(apps) + i + isSelected := idx == cursorIdx + vcApp := m.virtualContainerApp(containerDisplayName(vc)) + conns := 0 + estab := 0 + listen := 0 + if vcApp != nil { + conns = len(vcApp.Connections) + estab = vcApp.EstablishedCount + listen = vcApp.ListenCount + } + row := fmt.Sprintf("%-*s %-*s %*d %*d %*d %*s %*s", + widths[0], truncateString(vc.Info.ID, widths[0]), + widths[1], truncateString(containerDisplayName(vc), widths[1]), + widths[2], conns, + widths[3], estab, + widths[4], listen, + widths[5], "β€”", + widths[6], "β€”", + ) + b.WriteString(renderRow(row, isSelected)) + } + return b.String() } @@ -898,14 +1002,7 @@ func (m Model) renderConnectionsListData() string { return "" } - var selectedApp *model.Application - for i := range m.snapshot.Applications { - if m.snapshot.Applications[i].Name == view.ProcessName { - selectedApp = &m.snapshot.Applications[i] - break - } - } - + selectedApp := m.findSelectedApp(view.ProcessName) if selectedApp == nil { return EmptyStyle().Render("Process not found") } @@ -1170,24 +1267,32 @@ func (m Model) renderKillModalContent() string { var lines []string lines = append(lines, "") - // Title and PID info - multiPID := len(m.killTarget.PIDs) > 1 - if multiPID { - lines = append(lines, dangerStyle.Render(fmt.Sprintf(" Kill %d processes?", len(m.killTarget.PIDs)))) - } else { - lines = append(lines, dangerStyle.Render(" Kill this process?")) - } - lines = append(lines, "") - - // Process info (shared for both cases) - lines = append(lines, descStyle.Render(fmt.Sprintf(" Process: %s", m.killTarget.ProcessName))) - if m.killTarget.Exe != "" { - lines = append(lines, dimStyle.Render(fmt.Sprintf(" Path: %s", m.killTarget.Exe))) - } - if multiPID { - lines = append(lines, descStyle.Render(fmt.Sprintf(" PIDs: %s", formatPIDList(m.killTarget.PIDs)))) + // Title and target info + if m.killTarget.ContainerID != "" { + lines = append(lines, dangerStyle.Render(" Stop this container?")) + lines = append(lines, "") + lines = append(lines, descStyle.Render(fmt.Sprintf(" Container: %s", m.killTarget.ProcessName))) + if m.killTarget.Exe != "" { + lines = append(lines, dimStyle.Render(fmt.Sprintf(" Image: %s", m.killTarget.Exe))) + } + lines = append(lines, descStyle.Render(fmt.Sprintf(" ID: %s", m.killTarget.ContainerID))) } else { - lines = append(lines, descStyle.Render(fmt.Sprintf(" PID: %d", m.killTarget.PID))) + multiPID := len(m.killTarget.PIDs) > 1 + if multiPID { + lines = append(lines, dangerStyle.Render(fmt.Sprintf(" Kill %d processes?", len(m.killTarget.PIDs)))) + } else { + lines = append(lines, dangerStyle.Render(" Kill this process?")) + } + lines = append(lines, "") + lines = append(lines, descStyle.Render(fmt.Sprintf(" Process: %s", m.killTarget.ProcessName))) + if m.killTarget.Exe != "" { + lines = append(lines, dimStyle.Render(fmt.Sprintf(" Path: %s", m.killTarget.Exe))) + } + if multiPID { + lines = append(lines, descStyle.Render(fmt.Sprintf(" PIDs: %s", formatPIDList(m.killTarget.PIDs)))) + } else { + lines = append(lines, descStyle.Render(fmt.Sprintf(" PID: %d", m.killTarget.PID))) + } } // Signal radio options @@ -1292,6 +1397,7 @@ func (m Model) renderSettingsModalContent() string { {"Service Names", m.serviceNames, "Show http/https instead of 80/443"}, {"Highlight Changes", m.highlightChanges, "Flash new/removed connections"}, {"Animations", m.animations, "Enable UI animations (pulse, spinners)"}, + {"Docker Containers", m.dockerContainers, "Show containers as process rows"}, } for i, s := range settings {