feat(tui): add process features watch and kill
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user