initial commit

This commit is contained in:
Karol Broda
2025-12-16 22:42:49 +01:00
commit 371f4d13a6
61 changed files with 6872 additions and 0 deletions

352
internal/tui/view.go Normal file
View File

@@ -0,0 +1,352 @@
package tui
import (
"fmt"
"snitch/internal/collector"
"strings"
"time"
)
func (m model) renderMain() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(m.renderTitle())
b.WriteString("\n")
b.WriteString(m.renderFilters())
b.WriteString("\n\n")
b.WriteString(m.renderTableHeader())
b.WriteString(m.renderSeparator())
b.WriteString(m.renderConnections())
b.WriteString("\n")
b.WriteString(m.renderStatusLine())
return b.String()
}
func (m model) renderTitle() string {
visible := m.visibleConnections()
total := len(m.connections)
left := m.theme.Styles.Header.Render("snitch")
ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100)
right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections ↻ %s", len(visible), total, formatDuration(ago)))
w := m.safeWidth()
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
if gap < 0 {
gap = 0
}
return " " + left + strings.Repeat(" ", gap) + right
}
func (m model) renderFilters() string {
var parts []string
if m.showTCP {
parts = append(parts, m.theme.Styles.Success.Render("tcp"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("tcp"))
}
if m.showUDP {
parts = append(parts, m.theme.Styles.Success.Render("udp"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("udp"))
}
parts = append(parts, m.theme.Styles.Border.Render("│"))
if m.showListening {
parts = append(parts, m.theme.Styles.Success.Render("listen"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("listen"))
}
if m.showEstablished {
parts = append(parts, m.theme.Styles.Success.Render("estab"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("estab"))
}
if m.showOther {
parts = append(parts, m.theme.Styles.Success.Render("other"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("other"))
}
left := " " + strings.Join(parts, " ")
sortLabel := sortFieldLabel(m.sortField)
sortDir := "↑"
if m.sortReverse {
sortDir = "↓"
}
var right string
if m.searchActive {
right = m.theme.Styles.Warning.Render(fmt.Sprintf("/%s▌", m.searchQuery))
} else if m.searchQuery != "" {
right = m.theme.Styles.Normal.Render(fmt.Sprintf("filter: %s", m.searchQuery))
} else {
right = m.theme.Styles.Normal.Render(fmt.Sprintf("sort: %s %s", sortLabel, sortDir))
}
w := m.safeWidth()
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right + " "
}
func (m model) renderTableHeader() string {
cols := m.columnWidths()
header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %s",
cols.process, "PROCESS",
cols.port, "PORT",
cols.proto, "PROTO",
cols.state, "STATE",
cols.local, "LOCAL",
"REMOTE")
return m.theme.Styles.Header.Render(header) + "\n"
}
func (m model) renderSeparator() string {
w := m.width - 4
if w < 1 {
w = 76
}
line := " " + strings.Repeat("─", w)
return m.theme.Styles.Border.Render(line) + "\n"
}
func (m model) renderConnections() string {
var b strings.Builder
visible := m.visibleConnections()
pageSize := m.pageSize()
if len(visible) == 0 {
empty := "\n " + m.theme.Styles.Normal.Render("no connections match filters") + "\n"
return empty
}
start := m.scrollOffset(pageSize, len(visible))
for i := 0; i < pageSize; i++ {
idx := start + i
if idx >= len(visible) {
b.WriteString("\n")
continue
}
isSelected := idx == m.cursor
b.WriteString(m.renderRow(visible[idx], isSelected))
}
return b.String()
}
func (m model) renderRow(c collector.Connection, selected bool) string {
cols := m.columnWidths()
indicator := " "
if selected {
indicator = m.theme.Styles.Success.Render("▸ ")
}
process := truncate(c.Process, cols.process)
if process == "" {
process = ""
}
port := fmt.Sprintf("%d", c.Lport)
proto := c.Proto
state := c.State
if state == "" {
state = ""
}
local := c.Laddr
if local == "*" || local == "" {
local = "*"
}
remote := formatRemote(c.Raddr, c.Rport)
// apply styling
protoStyled := m.theme.Styles.GetProtoStyle(proto).Render(fmt.Sprintf("%-*s", cols.proto, proto))
stateStyled := m.theme.Styles.GetStateStyle(state).Render(fmt.Sprintf("%-*s", cols.state, truncate(state, cols.state)))
row := fmt.Sprintf("%s%-*s %-*s %s %s %-*s %s",
indicator,
cols.process, process,
cols.port, port,
protoStyled,
stateStyled,
cols.local, truncate(local, cols.local),
truncate(remote, cols.remote))
if selected {
return m.theme.Styles.Selected.Render(row) + "\n"
}
return m.theme.Styles.Normal.Render(row) + "\n"
}
func (m model) renderStatusLine() string {
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state s sort / search ? help q quit")
return left
}
func (m model) renderError() string {
return fmt.Sprintf("\n %s\n\n press q to quit\n",
m.theme.Styles.Error.Render(fmt.Sprintf("error: %v", m.err)))
}
func (m model) renderHelp() string {
help := `
navigation
──────────
j/k ↑/↓ move cursor
g/G jump to top/bottom
ctrl+d/u half page down/up
enter show connection details
filters
───────
t toggle tcp
u toggle udp
l toggle listening
e toggle established
o toggle other states
a reset all filters
sorting
───────
s cycle sort field
S reverse sort order
other
─────
/ search
r refresh now
q quit
press ? or esc to close
`
return m.theme.Styles.Normal.Render(help)
}
func (m model) renderDetail() string {
if m.selected == nil {
return ""
}
c := m.selected
var b strings.Builder
b.WriteString("\n")
b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n")
b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat("─", 40)) + "\n\n")
fields := []struct {
label string
value string
}{
{"process", c.Process},
{"pid", fmt.Sprintf("%d", c.PID)},
{"user", c.User},
{"protocol", c.Proto},
{"state", c.State},
{"local", fmt.Sprintf("%s:%d", c.Laddr, c.Lport)},
{"remote", fmt.Sprintf("%s:%d", c.Raddr, c.Rport)},
{"interface", c.Interface},
{"inode", fmt.Sprintf("%d", c.Inode)},
}
for _, f := range fields {
val := f.value
if val == "" || val == "0" || val == ":0" {
val = ""
}
line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val)
b.WriteString(line)
}
b.WriteString("\n")
b.WriteString(" " + m.theme.Styles.Normal.Render("press esc to close") + "\n")
return b.String()
}
func (m model) scrollOffset(pageSize, total int) int {
if total <= pageSize {
return 0
}
// keep cursor roughly centered
offset := m.cursor - pageSize/2
if offset < 0 {
offset = 0
}
if offset > total-pageSize {
offset = total - pageSize
}
return offset
}
type columns struct {
process int
port int
proto int
state int
local int
remote int
}
func (m model) columnWidths() columns {
available := m.safeWidth() - 16
c := columns{
process: 16,
port: 6,
proto: 5,
state: 11,
local: 15,
remote: 20,
}
used := c.process + c.port + c.proto + c.state + c.local + c.remote
extra := available - used
if extra > 0 {
c.process += extra / 3
c.remote += extra - extra/3
}
return c
}
func (m model) safeWidth() int {
if m.width < 80 {
return 80
}
return m.width
}
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
return fmt.Sprintf("%.0fm", d.Minutes())
}