diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 897e0b1..297035c 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -1,9 +1,13 @@ package scanner import ( + "bytes" + "encoding/json" "fmt" "net/http" "os" + "os/exec" + "strings" "time" "github.com/shirou/gopsutil/v3/net" @@ -12,15 +16,27 @@ import ( // PortInfo represents information about a listening port type PortInfo struct { - Port int - PID int32 - Process string - Status string - HTTPStatus int // HTTP response status code (0 if not checked) - Latency time.Duration // Response latency - CPUPercent float64 // CPU usage percentage - MemoryMB float64 // Memory usage in MB - Selected bool // For multi-select mode + Port int + PID int32 + Process string + Status string + HTTPStatus int // HTTP response status code (0 if not checked) + Latency time.Duration // Response latency + CPUPercent float64 // CPU usage percentage + MemoryMB float64 // Memory usage in MB + Selected bool // For multi-select mode + ContainerID string // Docker container ID (short form) + ContainerName string // Docker container name + IsContainer bool // Whether this process is in a container +} + +// DockerContainer represents a Docker container +type DockerContainer struct { + ID string `json:"ID"` + Name string `json:"Names"` + Image string `json:"Image"` + Ports string `json:"Ports"` + Status string `json:"Status"` } // ScanPorts scans for all active network connections @@ -44,6 +60,9 @@ func ScanPorts() ([]PortInfo, error) { pName := "Unknown" var cpuPercent, memoryMB float64 + var containerID, containerName string + var isContainer bool + if conn.Pid != 0 { p, err := process.NewProcess(conn.Pid) if err == nil { @@ -54,16 +73,22 @@ func ScanPorts() ([]PortInfo, error) { if err == nil { memoryMB = float64(memInfo.RSS) / 1024 / 1024 } + + // Check if process is in a Docker container + containerID, containerName, isContainer = getContainerInfo(conn.Pid) } } portInfo := PortInfo{ - Port: port, - PID: conn.Pid, - Process: pName, - Status: conn.Status, - CPUPercent: cpuPercent, - MemoryMB: memoryMB, + Port: port, + PID: conn.Pid, + Process: pName, + Status: conn.Status, + CPUPercent: cpuPercent, + MemoryMB: memoryMB, + ContainerID: containerID, + ContainerName: containerName, + IsContainer: isContainer, } // Check HTTP health for common web ports @@ -177,3 +202,131 @@ func KillMultipleProcesses(pids []int32) error { } return nil } + +// getContainerInfo checks if a PID is running in a Docker container +// Returns containerID (short), containerName, and isContainer bool +func getContainerInfo(pid int32) (string, string, bool) { + // Check if Docker is available + if !isDockerAvailable() { + return "", "", false + } + + // Read cgroup file to detect container ID + containerID := getContainerIDFromCgroup(pid) + if containerID == "" { + return "", "", false + } + + // Get container name from Docker + containerName := getContainerNameByID(containerID) + + // Return short container ID (first 12 chars) + shortID := containerID + if len(containerID) > 12 { + shortID = containerID[:12] + } + + return shortID, containerName, true +} + +// isDockerAvailable checks if Docker CLI is available +func isDockerAvailable() bool { + cmd := exec.Command("docker", "version") + err := cmd.Run() + return err == nil +} + +// getContainerIDFromCgroup reads the cgroup file to extract container ID +func getContainerIDFromCgroup(pid int32) string { + cgroupPath := fmt.Sprintf("/proc/%d/cgroup", pid) + data, err := os.ReadFile(cgroupPath) + if err != nil { + return "" + } + + // Look for docker container ID in cgroup + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "docker") { + // Extract container ID from paths like: + // 0::/docker/1234567890abcdef... + // 0::/system.slice/docker-1234567890abcdef.scope + parts := strings.Split(line, "/") + for _, part := range parts { + if strings.HasPrefix(part, "docker-") { + // Remove "docker-" prefix and ".scope" suffix + id := strings.TrimPrefix(part, "docker-") + id = strings.TrimSuffix(id, ".scope") + return id + } + // Check if part is a long hex string (container ID) + if len(part) == 64 { + return part + } + } + } + } + + return "" +} + +// getContainerNameByID gets the container name using Docker CLI +func getContainerNameByID(containerID string) string { + cmd := exec.Command("docker", "inspect", "--format={{.Name}}", containerID) + output, err := cmd.Output() + if err != nil { + return "" + } + + name := strings.TrimSpace(string(output)) + // Remove leading slash from container name + name = strings.TrimPrefix(name, "/") + return name +} + +// ListDockerContainers returns all running Docker containers +func ListDockerContainers() ([]DockerContainer, error) { + if !isDockerAvailable() { + return nil, fmt.Errorf("docker is not available") + } + + cmd := exec.Command("docker", "ps", "--format", "{{json .}}") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var containers []DockerContainer + lines := bytes.Split(output, []byte("\n")) + for _, line := range lines { + if len(line) == 0 { + continue + } + var container DockerContainer + if err := json.Unmarshal(line, &container); err == nil { + containers = append(containers, container) + } + } + + return containers, nil +} + +// StopContainer stops a Docker container by ID or name +func StopContainer(containerID string) error { + if !isDockerAvailable() { + return fmt.Errorf("docker is not available") + } + + cmd := exec.Command("docker", "stop", containerID) + return cmd.Run() +} + +// RestartContainer restarts a Docker container by ID or name +func RestartContainer(containerID string) error { + if !isDockerAvailable() { + return fmt.Errorf("docker is not available") + } + + cmd := exec.Command("docker", "restart", containerID) + return cmd.Run() +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c03b189..225d041 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -37,7 +37,7 @@ var ( Foreground(lipgloss.Color("#00D9FF")) pidStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")) + Foreground(lipgloss.Color("#888888")) processStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#88FF88")) @@ -69,25 +69,31 @@ var ( Bold(true) // HTTP status styles httpOKStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00FF00")) + Foreground(lipgloss.Color("#00FF00")) httpErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5555")) + Foreground(lipgloss.Color("#FF5555")) // Port type styles wellKnownPortStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")). - Bold(true) + Foreground(lipgloss.Color("#FF6B6B")). + Bold(true) registeredPortStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4ECDC4")) + Foreground(lipgloss.Color("#4ECDC4")) dynamicPortStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#95E1D3")) + Foreground(lipgloss.Color("#95E1D3")) // Metrics styles metricsStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFA500"))) + Foreground(lipgloss.Color("#FFA500")) + + // Docker styles + dockerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2496ED")). + Bold(true) +) type tickMsg time.Time type scanResultMsg []scanner.PortInfo @@ -113,19 +119,20 @@ const ( // Model represents the application state type Model struct { - ports []scanner.PortInfo - cursor int - table table.Model - err error - lastScan time.Time - isScanning bool - sortColumn SortColumn - sortAscending bool + ports []scanner.PortInfo + cursor int + table table.Model + err error + lastScan time.Time + isScanning bool + sortColumn SortColumn + sortAscending bool historyTracker *history.Tracker - viewMode ViewMode - exportMsg string - exportMsgTime time.Time - showMetrics bool // Toggle for showing CPU/Memory metrics + viewMode ViewMode + exportMsg string + exportMsgTime time.Time + showMetrics bool // Toggle for showing CPU/Memory metrics + showDocker bool // Toggle for showing Docker container info } // InitialModel creates the initial model @@ -133,9 +140,10 @@ func InitialModel() Model { columns := []table.Column{ {Title: "Port", Width: 10}, {Title: "PID", Width: 10}, - {Title: "Process", Width: 25}, + {Title: "Process", Width: 20}, + {Title: "Docker", Width: 15}, {Title: "HTTP", Width: 8}, - {Title: "Uptime", Width: 15}, + {Title: "Uptime", Width: 12}, {Title: "Status", Width: 10}, } @@ -168,7 +176,7 @@ func InitialModel() Model { sortColumn: SortByPort, sortAscending: true, historyTracker: history.NewTracker(1000, 500), // Track last 1000 events, 500 ports - viewMode: ViewPorts, showMetrics: false, } + viewMode: ViewPorts, showMetrics: false, showDocker: true} } // Init initializes the model @@ -236,6 +244,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTableRows() } + case "d", "D": + // Toggle Docker info display + m.showDocker = !m.showDocker + if m.viewMode == ViewPorts { + m.updateTableRows() + } + + case "x", "X": + // Stop Docker container + if len(m.ports) > 0 && m.table.Cursor() < len(m.ports) { + selectedPort := m.ports[m.table.Cursor()] + if selectedPort.IsContainer && selectedPort.ContainerID != "" { + err := scanner.StopContainer(selectedPort.ContainerID) + if err != nil { + m.err = fmt.Errorf("failed to stop container: %w", err) + } else { + m.exportMsg = fmt.Sprintf("Stopped container: %s", selectedPort.ContainerName) + m.exportMsgTime = time.Now() + return m, scanPorts() + } + } else { + m.err = fmt.Errorf("selected port is not in a container") + } + } + + case "t", "T": + // Restart Docker container + if len(m.ports) > 0 && m.table.Cursor() < len(m.ports) { + selectedPort := m.ports[m.table.Cursor()] + if selectedPort.IsContainer && selectedPort.ContainerID != "" { + err := scanner.RestartContainer(selectedPort.ContainerID) + if err != nil { + m.err = fmt.Errorf("failed to restart container: %w", err) + } else { + m.exportMsg = fmt.Sprintf("Restarted container: %s", selectedPort.ContainerName) + m.exportMsgTime = time.Now() + return m, scanPorts() + } + } else { + m.err = fmt.Errorf("selected port is not in a container") + } + } + case "e", "E": // Export current data if len(m.ports) > 0 { @@ -337,7 +388,7 @@ func (m Model) View() string { // Help text if m.viewMode == ViewPorts { - help := "↑/↓: Navigate • s: Sort • a: Order • m: Metrics • e: Export • h: History • k: Kill • r: Refresh • q: Quit" + help := "↑/↓: Navigate • s: Sort • a: Order • m: Metrics • d: Docker • e: Export • h: History • k: Kill • x: Stop • t: Restart • r: Refresh • q: Quit" s += helpStyle.Render(help) } else { help := "↑/↓: Navigate • h: Back to Ports • e: Export • q: Quit" @@ -388,27 +439,29 @@ func (m *Model) sortPorts() { func (m *Model) updateTableRows() { // Clear rows first to prevent index out of range panic when column count changes m.table.SetRows([]table.Row{}) - + // Update columns based on metrics toggle var columns []table.Column if m.showMetrics { columns = []table.Column{ {Title: "Port", Width: 10}, {Title: "PID", Width: 10}, - {Title: "Process", Width: 20}, - {Title: "HTTP", Width: 8}, - {Title: "Latency", Width: 10}, - {Title: "CPU%", Width: 8}, - {Title: "Mem(MB)", Width: 10}, - {Title: "Uptime", Width: 12}, + {Title: "Process", Width: 18}, + {Title: "Docker", Width: 12}, + {Title: "HTTP", Width: 7}, + {Title: "Latency", Width: 9}, + {Title: "CPU%", Width: 7}, + {Title: "Mem(MB)", Width: 9}, + {Title: "Uptime", Width: 10}, } } else { columns = []table.Column{ {Title: "Port", Width: 10}, {Title: "PID", Width: 10}, - {Title: "Process", Width: 25}, + {Title: "Process", Width: 20}, + {Title: "Docker", Width: 15}, {Title: "HTTP", Width: 8}, - {Title: "Uptime", Width: 15}, + {Title: "Uptime", Width: 12}, {Title: "Status", Width: 10}, } } @@ -417,24 +470,35 @@ func (m *Model) updateTableRows() { rows := []table.Row{} for _, p := range m.ports { uptime := history.FormatUptime(m.historyTracker.GetUptime(p.Port)) - + // HTTP status display httpStatus := "-" if p.HTTPStatus > 0 { httpStatus = fmt.Sprintf("%d", p.HTTPStatus) } - + // Latency display latency := "-" if p.Latency > 0 { latency = fmt.Sprintf("%dms", p.Latency.Milliseconds()) } - + + // Docker display + dockerInfo := "-" + if p.IsContainer && m.showDocker { + if p.ContainerName != "" { + dockerInfo = p.ContainerName + } else if p.ContainerID != "" { + dockerInfo = p.ContainerID[:8] // Show first 8 chars + } + } + if m.showMetrics { rows = append(rows, table.Row{ fmt.Sprintf("%d", p.Port), fmt.Sprintf("%d", p.PID), p.Process, + dockerInfo, httpStatus, latency, fmt.Sprintf("%.1f", p.CPUPercent), @@ -446,6 +510,7 @@ func (m *Model) updateTableRows() { fmt.Sprintf("%d", p.Port), fmt.Sprintf("%d", p.PID), p.Process, + dockerInfo, httpStatus, uptime, p.Status, @@ -479,7 +544,7 @@ func (m Model) getSortIndicator() string { func (m *Model) updateHistoryTable() { // Clear rows first to prevent index out of range panic when column count changes m.table.SetRows([]table.Row{}) - + // Update columns for history view columns := []table.Column{ {Title: "Port", Width: 10}, @@ -546,4 +611,3 @@ func exportData(ports []scanner.PortInfo) tea.Cmd { return exportSuccessMsg{path: paths} } } -