feat(tui): add process features watch and kill

This commit is contained in:
Karol Broda
2025-12-20 19:11:58 +01:00
parent 2c9ce1445f
commit 99f1d95295
5 changed files with 391 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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{}
})
}

View File

@@ -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)
}

View File

@@ -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