From 99f1d95295f3133d36674cfd05b428c13a0efba4 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sat, 20 Dec 2025 19:11:58 +0100 Subject: [PATCH] feat(tui): add process features watch and kill --- README.md | 3 + internal/tui/keys.go | 75 +++++++++++++++ internal/tui/messages.go | 48 ++++++++++ internal/tui/model.go | 72 +++++++++++++- internal/tui/view.go | 198 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 391 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ed516bb..f7ad8d8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ g/G top/bottom t/u toggle tcp/udp l/e/o toggle listen/established/other s/S cycle sort / reverse +w watch/monitor process (highlight) +W clear all watched +K kill process (with confirmation) / search enter connection details ? help diff --git a/internal/tui/keys.go b/internal/tui/keys.go index deacd03..6858fcd 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,7 +1,9 @@ package tui import ( + "fmt" "snitch/internal/collector" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -12,6 +14,11 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSearchKey(msg) } + // kill confirmation dialog + if m.showKillConfirm { + return m.handleKillConfirmKey(msg) + } + // detail view only allows closing if m.showDetail { return m.handleDetailKey(msg) @@ -62,6 +69,25 @@ func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m model) handleKillConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + if m.killTarget != nil && m.killTarget.PID > 0 { + pid := m.killTarget.PID + process := m.killTarget.Process + m.showKillConfirm = false + m.killTarget = nil + return m, killProcess(pid, process) + } + m.showKillConfirm = false + m.killTarget = nil + case "n", "N", "esc", "q": + m.showKillConfirm = false + m.killTarget = nil + } + return m, nil +} + func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": @@ -135,6 +161,55 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.fetchData() case "?": m.showHelp = true + + // watch/monitor process + case "w": + visible := m.visibleConnections() + if m.cursor < len(visible) { + conn := visible[m.cursor] + if conn.PID > 0 { + wasWatched := m.isWatched(conn.PID) + m.toggleWatch(conn.PID) + + // count connections for this pid + connCount := 0 + for _, c := range m.connections { + if c.PID == conn.PID { + connCount++ + } + } + + if wasWatched { + m.statusMessage = fmt.Sprintf("unwatched %s (pid %d)", conn.Process, conn.PID) + } else if connCount > 1 { + m.statusMessage = fmt.Sprintf("watching %s (pid %d) - %d connections", conn.Process, conn.PID, connCount) + } else { + m.statusMessage = fmt.Sprintf("watching %s (pid %d)", conn.Process, conn.PID) + } + m.statusExpiry = time.Now().Add(2 * time.Second) + return m, clearStatusAfter(2 * time.Second) + } + } + case "W": + // clear all watched + count := len(m.watchedPIDs) + m.watchedPIDs = make(map[int]bool) + if count > 0 { + m.statusMessage = fmt.Sprintf("cleared %d watched processes", count) + m.statusExpiry = time.Now().Add(2 * time.Second) + return m, clearStatusAfter(2 * time.Second) + } + + // kill process + case "K": + visible := m.visibleConnections() + if m.cursor < len(visible) { + conn := visible[m.cursor] + if conn.PID > 0 { + m.killTarget = &conn + m.showKillConfirm = true + } + } } return m, nil diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 46fc63c..e6dffca 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -1,7 +1,9 @@ package tui import ( + "fmt" "snitch/internal/collector" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -17,6 +19,15 @@ type errMsg struct { err error } +type killResultMsg struct { + pid int + process string + success bool + err error +} + +type clearStatusMsg struct{} + func (m model) tick() tea.Cmd { return tea.Tick(m.interval, func(t time.Time) tea.Msg { return tickMsg(t) @@ -33,3 +44,40 @@ func (m model) fetchData() tea.Cmd { } } +func killProcess(pid int, process string) tea.Cmd { + return func() tea.Msg { + if pid <= 0 { + return killResultMsg{ + pid: pid, + process: process, + success: false, + err: fmt.Errorf("invalid pid"), + } + } + + // send SIGTERM first (graceful shutdown) + err := syscall.Kill(pid, syscall.SIGTERM) + if err != nil { + return killResultMsg{ + pid: pid, + process: process, + success: false, + err: err, + } + } + + return killResultMsg{ + pid: pid, + process: process, + success: true, + err: nil, + } + } +} + +func clearStatusAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { + return clearStatusMsg{} + }) +} + diff --git a/internal/tui/model.go b/internal/tui/model.go index 0343e69..82ae63f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "snitch/internal/collector" "snitch/internal/theme" "time" @@ -35,6 +36,17 @@ type model struct { interval time.Duration lastRefresh time.Time err error + + // watched processes + watchedPIDs map[int]bool + + // kill confirmation + showKillConfirm bool + killTarget *collector.Connection + + // status message (temporary feedback) + statusMessage string + statusExpiry time.Time } type Options struct { @@ -93,6 +105,7 @@ func New(opts Options) model { theme: theme.GetTheme(opts.Theme), interval: interval, lastRefresh: time.Now(), + watchedPIDs: make(map[int]bool), } } @@ -127,6 +140,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case errMsg: m.err = msg.err return m, nil + + case killResultMsg: + if msg.success { + m.statusMessage = fmt.Sprintf("killed %s (pid %d)", msg.process, msg.pid) + } else { + m.statusMessage = fmt.Sprintf("failed to kill pid %d: %v", msg.pid, msg.err) + } + m.statusExpiry = time.Now().Add(3 * time.Second) + return m, tea.Batch(m.fetchData(), clearStatusAfter(3*time.Second)) + + case clearStatusMsg: + if time.Now().After(m.statusExpiry) { + m.statusMessage = "" + } + return m, nil } return m, nil @@ -142,7 +170,15 @@ func (m model) View() string { if m.showDetail && m.selected != nil { return m.renderDetail() } - return m.renderMain() + + main := m.renderMain() + + // overlay kill confirmation modal on top of main view + if m.showKillConfirm && m.killTarget != nil { + return m.overlayModal(main, m.renderKillModal()) + } + + return main } func (m *model) applySorting() { @@ -167,7 +203,8 @@ func (m *model) clampCursor() { } func (m model) visibleConnections() []collector.Connection { - var result []collector.Connection + var watched []collector.Connection + var unwatched []collector.Connection for _, c := range m.connections { if !m.matchesFilters(c) { @@ -176,10 +213,15 @@ func (m model) visibleConnections() []collector.Connection { if m.searchQuery != "" && !m.matchesSearch(c) { continue } - result = append(result, c) + if m.isWatched(c.PID) { + watched = append(watched, c) + } else { + unwatched = append(unwatched, c) + } } - return result + // watched connections appear first + return append(watched, unwatched...) } func (m model) matchesFilters(c collector.Connection) bool { @@ -218,3 +260,25 @@ func (m model) matchesSearch(c collector.Connection) bool { containsIgnoreCase(c.Proto, m.searchQuery) || containsIgnoreCase(c.State, m.searchQuery) } + +func (m model) isWatched(pid int) bool { + if pid <= 0 { + return false + } + return m.watchedPIDs[pid] +} + +func (m *model) toggleWatch(pid int) { + if pid <= 0 { + return + } + if m.watchedPIDs[pid] { + delete(m.watchedPIDs, pid) + } else { + m.watchedPIDs[pid] = true + } +} + +func (m model) watchedCount() int { + return len(m.watchedPIDs) +} diff --git a/internal/tui/view.go b/internal/tui/view.go index b6daf94..4e21c51 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -158,6 +158,8 @@ func (m model) renderRow(c collector.Connection, selected bool) string { indicator := " " if selected { indicator = m.theme.Styles.Success.Render("▸ ") + } else if m.isWatched(c.PID) { + indicator = m.theme.Styles.Watched.Render("★ ") } process := truncate(c.Process, cols.process) @@ -200,7 +202,18 @@ func (m model) renderRow(c collector.Connection, selected bool) string { } func (m model) renderStatusLine() string { - left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state s sort / search ? help q quit") + // show status message if present + if m.statusMessage != "" { + return " " + m.theme.Styles.Warning.Render(m.statusMessage) + } + + left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state w watch K kill s sort / search ? help q quit") + + // show watched count if any + if m.watchedCount() > 0 { + watchedInfo := fmt.Sprintf(" watching: %d", m.watchedCount()) + left += m.theme.Styles.Watched.Render(watchedInfo) + } return left } @@ -233,6 +246,12 @@ func (m model) renderHelp() string { s cycle sort field S reverse sort order + process management + ────────────────── + w watch/unwatch process (highlight & track) + W clear all watched processes + K kill process (with confirmation) + other ───── / search @@ -286,6 +305,183 @@ func (m model) renderDetail() string { return b.String() } +func (m model) renderKillModal() string { + if m.killTarget == nil { + return "" + } + + c := m.killTarget + processName := c.Process + if processName == "" { + processName = "(unknown)" + } + + // count how many connections this process has + connCount := 0 + for _, conn := range m.connections { + if conn.PID == c.PID { + connCount++ + } + } + + // build modal content + var lines []string + lines = append(lines, "") + lines = append(lines, m.theme.Styles.Error.Render(" !! KILL PROCESS? ")) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName))) + lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID)))) + lines = append(lines, fmt.Sprintf(" user: %s", c.User)) + lines = append(lines, fmt.Sprintf(" conns: %d", connCount)) + lines = append(lines, "") + lines = append(lines, m.theme.Styles.Warning.Render(" sends SIGTERM to process")) + if connCount > 1 { + lines = append(lines, m.theme.Styles.Warning.Render(fmt.Sprintf(" will close all %d connections", connCount))) + } + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" %s confirm %s cancel", + m.theme.Styles.Success.Render("[y]"), + m.theme.Styles.Error.Render("[n]"))) + lines = append(lines, "") + + return strings.Join(lines, "\n") +} + +func (m model) overlayModal(background, modal string) string { + bgLines := strings.Split(background, "\n") + modalLines := strings.Split(modal, "\n") + + // find max modal line width using runewidth for proper unicode handling + modalWidth := 0 + for _, line := range modalLines { + w := runeWidth(stripAnsi(line)) + if w > modalWidth { + modalWidth = w + } + } + modalWidth += 4 // padding for box + + modalHeight := len(modalLines) + boxWidth := modalWidth + 2 // include border chars │ │ + + // calculate modal position (centered) + startRow := (m.height - modalHeight) / 2 + if startRow < 2 { + startRow = 2 + } + startCol := (m.width - boxWidth) / 2 + if startCol < 0 { + startCol = 0 + } + + // build result + result := make([]string, len(bgLines)) + copy(result, bgLines) + + // ensure we have enough lines + for len(result) < startRow+modalHeight+2 { + result = append(result, strings.Repeat(" ", m.width)) + } + + // helper to build a line with modal overlay + buildLine := func(bgLine, modalContent string) string { + modalVisibleWidth := runeWidth(stripAnsi(modalContent)) + endCol := startCol + modalVisibleWidth + + leftBg := visibleSubstring(bgLine, 0, startCol) + rightBg := visibleSubstring(bgLine, endCol, m.width) + + // pad left side if needed + leftLen := runeWidth(stripAnsi(leftBg)) + if leftLen < startCol { + leftBg = leftBg + strings.Repeat(" ", startCol-leftLen) + } + + return leftBg + modalContent + rightBg + } + + // draw top border + borderRow := startRow - 1 + if borderRow >= 0 && borderRow < len(result) { + border := m.theme.Styles.Border.Render("╭" + strings.Repeat("─", modalWidth) + "╮") + result[borderRow] = buildLine(result[borderRow], border) + } + + // draw modal content with side borders + for i, line := range modalLines { + row := startRow + i + if row >= 0 && row < len(result) { + content := line + padding := modalWidth - runeWidth(stripAnsi(line)) + if padding > 0 { + content = line + strings.Repeat(" ", padding) + } + boxedLine := m.theme.Styles.Border.Render("│") + content + m.theme.Styles.Border.Render("│") + result[row] = buildLine(result[row], boxedLine) + } + } + + // draw bottom border + bottomRow := startRow + modalHeight + if bottomRow >= 0 && bottomRow < len(result) { + border := m.theme.Styles.Border.Render("╰" + strings.Repeat("─", modalWidth) + "╯") + result[bottomRow] = buildLine(result[bottomRow], border) + } + + return strings.Join(result, "\n") +} + +// runeWidth returns the display width of a string (assumes standard terminal chars) +func runeWidth(s string) int { + return len([]rune(s)) +} + +// visibleSubstring extracts a substring by visible column positions, preserving ANSI codes +func visibleSubstring(s string, start, end int) string { + if start >= end { + return "" + } + + var result strings.Builder + visiblePos := 0 + inEscape := false + runes := []rune(s) + + for i := 0; i < len(runes); i++ { + r := runes[i] + + // detect start of ANSI escape sequence + if r == '\x1b' && i+1 < len(runes) && runes[i+1] == '[' { + inEscape = true + // always include ANSI codes so colors carry over + result.WriteRune(r) + continue + } + + if inEscape { + // include escape sequence characters + result.WriteRune(r) + // check for end of escape sequence (letter) + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEscape = false + } + continue + } + + // regular visible character + if visiblePos >= start && visiblePos < end { + result.WriteRune(r) + } + visiblePos++ + + if visiblePos >= end { + break + } + } + + return result.String() +} + func (m model) scrollOffset(pageSize, total int) int { if total <= pageSize { return 0