feat: introduce theme management and performance improvements (#7)
This commit is contained in:
@@ -3,11 +3,12 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"github.com/karol-broda/snitch/internal/config"
|
|
||||||
|
|
||||||
|
"github.com/karol-broda/snitch/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFile string
|
cfgFile string
|
||||||
)
|
)
|
||||||
@@ -42,7 +43,7 @@ func init() {
|
|||||||
|
|
||||||
// add top's flags to root so `snitch -l` works (defaults to top command)
|
// add top's flags to root so `snitch -l` works (defaults to top command)
|
||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
|
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')")
|
||||||
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
|
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
|
||||||
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
|
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
|
||||||
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
|
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
|
||||||
|
|||||||
24
cmd/themes.go
Normal file
24
cmd/themes.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/karol-broda/snitch/internal/theme"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var themesCmd = &cobra.Command{
|
||||||
|
Use: "themes",
|
||||||
|
Short: "List available themes",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf("Available themes (default: %s):\n\n", theme.DefaultTheme)
|
||||||
|
for _, name := range theme.ListThemes() {
|
||||||
|
fmt.Printf(" %s\n", name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(themesCmd)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,11 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"github.com/karol-broda/snitch/internal/config"
|
|
||||||
"github.com/karol-broda/snitch/internal/tui"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/karol-broda/snitch/internal/config"
|
||||||
|
"github.com/karol-broda/snitch/internal/tui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ func init() {
|
|||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
|
|
||||||
// top-specific flags
|
// top-specific flags
|
||||||
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
|
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')")
|
||||||
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
|
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
|
||||||
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
|
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
|
||||||
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
|
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
|
||||||
|
|||||||
@@ -11,21 +11,76 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// set SNITCH_DEBUG_TIMING=1 to enable timing diagnostics
|
||||||
|
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
|
||||||
|
|
||||||
|
func logTiming(label string, start time.Time, extra ...string) {
|
||||||
|
if !debugTiming {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if len(extra) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[timing] %s: %v (%s)\n", label, elapsed, extra[0])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[timing] %s: %v\n", label, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userCache caches uid to username mappings to avoid repeated lookups
|
||||||
|
var userCache = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
m map[int]string
|
||||||
|
}{m: make(map[int]string)}
|
||||||
|
|
||||||
|
func lookupUsername(uid int) string {
|
||||||
|
userCache.RLock()
|
||||||
|
if username, exists := userCache.m[uid]; exists {
|
||||||
|
userCache.RUnlock()
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
userCache.RUnlock()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
username := strconv.Itoa(uid)
|
||||||
|
u, err := user.LookupId(strconv.Itoa(uid))
|
||||||
|
if err == nil && u != nil {
|
||||||
|
username = u.Username
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if debugTiming && elapsed > 10*time.Millisecond {
|
||||||
|
fmt.Fprintf(os.Stderr, "[timing] user.LookupId(%d) slow: %v\n", uid, elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCache.Lock()
|
||||||
|
userCache.m[uid] = username
|
||||||
|
userCache.Unlock()
|
||||||
|
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultCollector implements the Collector interface using /proc filesystem
|
// DefaultCollector implements the Collector interface using /proc filesystem
|
||||||
type DefaultCollector struct{}
|
type DefaultCollector struct{}
|
||||||
|
|
||||||
// GetConnections fetches all network connections by parsing /proc files
|
// GetConnections fetches all network connections by parsing /proc files
|
||||||
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
|
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
|
||||||
|
totalStart := time.Now()
|
||||||
|
defer func() { logTiming("GetConnections total", totalStart) }()
|
||||||
|
|
||||||
|
inodeStart := time.Now()
|
||||||
inodeMap, err := buildInodeToProcessMap()
|
inodeMap, err := buildInodeToProcessMap()
|
||||||
|
logTiming("buildInodeToProcessMap", inodeStart, fmt.Sprintf("%d inodes", len(inodeMap)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build inode map: %w", err)
|
return nil, fmt.Errorf("failed to build inode map: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var connections []Connection
|
var connections []Connection
|
||||||
|
|
||||||
|
parseStart := time.Now()
|
||||||
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
|
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
connections = append(connections, tcpConns...)
|
connections = append(connections, tcpConns...)
|
||||||
@@ -45,6 +100,7 @@ func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
connections = append(connections, udpConns6...)
|
connections = append(connections, udpConns6...)
|
||||||
}
|
}
|
||||||
|
logTiming("parseProcNet (all)", parseStart, fmt.Sprintf("%d connections", len(connections)))
|
||||||
|
|
||||||
return connections, nil
|
return connections, nil
|
||||||
}
|
}
|
||||||
@@ -71,9 +127,13 @@ type processInfo struct {
|
|||||||
user string
|
user string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
|
type inodeEntry struct {
|
||||||
inodeMap := make(map[int64]*processInfo)
|
inode int64
|
||||||
|
info *processInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
|
||||||
|
readDirStart := time.Now()
|
||||||
procDir, err := os.Open("/proc")
|
procDir, err := os.Open("/proc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -85,47 +145,103 @@ func buildInodeToProcessMap() (map[int64]*processInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collect pids first
|
||||||
|
pids := make([]int, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
pid, err := strconv.Atoi(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pids = append(pids, pid)
|
||||||
|
}
|
||||||
|
logTiming(" readdir /proc", readDirStart, fmt.Sprintf("%d pids", len(pids)))
|
||||||
|
|
||||||
pidStr := entry.Name()
|
// process pids in parallel with limited concurrency
|
||||||
pid, err := strconv.Atoi(pidStr)
|
scanStart := time.Now()
|
||||||
|
const numWorkers = 8
|
||||||
|
pidChan := make(chan int, len(pids))
|
||||||
|
resultChan := make(chan []inodeEntry, len(pids))
|
||||||
|
|
||||||
|
var totalFDs atomic.Int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for pid := range pidChan {
|
||||||
|
entries := scanProcessSockets(pid)
|
||||||
|
if len(entries) > 0 {
|
||||||
|
totalFDs.Add(int64(len(entries)))
|
||||||
|
resultChan <- entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pid := range pids {
|
||||||
|
pidChan <- pid
|
||||||
|
}
|
||||||
|
close(pidChan)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
inodeMap := make(map[int64]*processInfo)
|
||||||
|
for entries := range resultChan {
|
||||||
|
for _, e := range entries {
|
||||||
|
inodeMap[e.inode] = e.info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logTiming(" scan all processes", scanStart, fmt.Sprintf("%d socket fds scanned", totalFDs.Load()))
|
||||||
|
|
||||||
|
return inodeMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanProcessSockets(pid int) []inodeEntry {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
procInfo, err := getProcessInfo(pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pidStr := strconv.Itoa(pid)
|
||||||
|
fdDir := filepath.Join("/proc", pidStr, "fd")
|
||||||
|
fdEntries, err := os.ReadDir(fdDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []inodeEntry
|
||||||
|
for _, fdEntry := range fdEntries {
|
||||||
|
fdPath := filepath.Join(fdDir, fdEntry.Name())
|
||||||
|
link, err := os.Readlink(fdPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
procInfo, err := getProcessInfo(pid)
|
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
|
||||||
if err != nil {
|
inodeStr := link[8 : len(link)-1]
|
||||||
continue
|
inode, err := strconv.ParseInt(inodeStr, 10, 64)
|
||||||
}
|
|
||||||
|
|
||||||
fdDir := filepath.Join("/proc", pidStr, "fd")
|
|
||||||
fdEntries, err := os.ReadDir(fdDir)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fdEntry := range fdEntries {
|
|
||||||
fdPath := filepath.Join(fdDir, fdEntry.Name())
|
|
||||||
link, err := os.Readlink(fdPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
results = append(results, inodeEntry{inode: inode, info: procInfo})
|
||||||
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
|
|
||||||
inodeStr := link[8 : len(link)-1]
|
|
||||||
inode, err := strconv.ParseInt(inodeStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inodeMap[inode] = procInfo
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return inodeMap, nil
|
elapsed := time.Since(start)
|
||||||
|
if debugTiming && elapsed > 20*time.Millisecond {
|
||||||
|
fmt.Fprintf(os.Stderr, "[timing] slow process scan: pid=%d (%s) fds=%d time=%v\n",
|
||||||
|
pid, procInfo.command, len(fdEntries), elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProcessInfo(pid int) (*processInfo, error) {
|
func getProcessInfo(pid int) (*processInfo, error) {
|
||||||
@@ -173,12 +289,7 @@ func getProcessInfo(pid int) (*processInfo, error) {
|
|||||||
uid, err := strconv.Atoi(fields[1])
|
uid, err := strconv.Atoi(fields[1])
|
||||||
if err == nil {
|
if err == nil {
|
||||||
info.uid = uid
|
info.uid = uid
|
||||||
u, err := user.LookupId(strconv.Itoa(uid))
|
info.user = lookupUsername(uid)
|
||||||
if err == nil {
|
|
||||||
info.user = u.Username
|
|
||||||
} else {
|
|
||||||
info.user = strconv.Itoa(uid)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
package collector
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetConnections(t *testing.T) {
|
func TestGetConnections(t *testing.T) {
|
||||||
@@ -14,3 +17,101 @@ func TestGetConnections(t *testing.T) {
|
|||||||
// connections are dynamic, so just verify function succeeded
|
// connections are dynamic, so just verify function succeeded
|
||||||
t.Logf("Successfully got %d connections", len(conns))
|
t.Logf("Successfully got %d connections", len(conns))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConnectionsPerformance(t *testing.T) {
|
||||||
|
// measures performance to catch regressions
|
||||||
|
// run with: go test -v -run TestGetConnectionsPerformance
|
||||||
|
|
||||||
|
const maxDuration = 500 * time.Millisecond
|
||||||
|
const iterations = 5
|
||||||
|
|
||||||
|
// warm up caches first
|
||||||
|
_, err := GetConnections()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("warmup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total time.Duration
|
||||||
|
var maxSeen time.Duration
|
||||||
|
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
start := time.Now()
|
||||||
|
conns, err := GetConnections()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iteration %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total += elapsed
|
||||||
|
if elapsed > maxSeen {
|
||||||
|
maxSeen = elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("iteration %d: %v (%d connections)", i+1, elapsed, len(conns))
|
||||||
|
}
|
||||||
|
|
||||||
|
avg := total / time.Duration(iterations)
|
||||||
|
t.Logf("average: %v, max: %v", avg, maxSeen)
|
||||||
|
|
||||||
|
if maxSeen > maxDuration {
|
||||||
|
t.Errorf("slowest iteration took %v, expected < %v", maxSeen, maxDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConnectionsColdCache(t *testing.T) {
|
||||||
|
// tests performance with cold user cache
|
||||||
|
// this simulates first run or after cache invalidation
|
||||||
|
|
||||||
|
const maxDuration = 2 * time.Second
|
||||||
|
|
||||||
|
clearUserCache()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
conns, err := GetConnections()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetConnections() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("cold cache: %v (%d connections, %d cached users after)",
|
||||||
|
elapsed, len(conns), userCacheSize())
|
||||||
|
|
||||||
|
if elapsed > maxDuration {
|
||||||
|
t.Errorf("cold cache took %v, expected < %v", elapsed, maxDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGetConnections(b *testing.B) {
|
||||||
|
// warm cache benchmark - measures typical runtime
|
||||||
|
// run with: go test -bench=BenchmarkGetConnections -benchtime=5s
|
||||||
|
|
||||||
|
// warm up
|
||||||
|
_, _ = GetConnections()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = GetConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGetConnectionsColdCache(b *testing.B) {
|
||||||
|
// cold cache benchmark - measures worst-case with cache cleared each iteration
|
||||||
|
// run with: go test -bench=BenchmarkGetConnectionsColdCache -benchtime=10s
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
clearUserCache()
|
||||||
|
_, _ = GetConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkBuildInodeMap(b *testing.B) {
|
||||||
|
// benchmarks just the inode map building (most expensive part)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = buildInodeToProcessMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/collector/collector_test_helpers_linux.go
Normal file
18
internal/collector/collector_test_helpers_linux.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package collector
|
||||||
|
|
||||||
|
// clearUserCache clears the user lookup cache for testing
|
||||||
|
func clearUserCache() {
|
||||||
|
userCache.Lock()
|
||||||
|
userCache.m = make(map[int]string)
|
||||||
|
userCache.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// userCacheSize returns the number of cached user entries
|
||||||
|
func userCacheSize() int {
|
||||||
|
userCache.RLock()
|
||||||
|
defer userCache.RUnlock()
|
||||||
|
return len(userCache.m)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/karol-broda/snitch/internal/theme"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("defaults.interval", "1s")
|
v.SetDefault("defaults.interval", "1s")
|
||||||
v.SetDefault("defaults.numeric", false)
|
v.SetDefault("defaults.numeric", false)
|
||||||
v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"})
|
v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"})
|
||||||
v.SetDefault("defaults.theme", "auto")
|
v.SetDefault("defaults.theme", "ansi")
|
||||||
v.SetDefault("defaults.units", "auto")
|
v.SetDefault("defaults.units", "auto")
|
||||||
v.SetDefault("defaults.color", "auto")
|
v.SetDefault("defaults.color", "auto")
|
||||||
v.SetDefault("defaults.resolve", true)
|
v.SetDefault("defaults.resolve", true)
|
||||||
@@ -127,7 +129,7 @@ func Get() *Config {
|
|||||||
Interval: "1s",
|
Interval: "1s",
|
||||||
Numeric: false,
|
Numeric: false,
|
||||||
Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"},
|
Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"},
|
||||||
Theme: "auto",
|
Theme: "ansi",
|
||||||
Units: "auto",
|
Units: "auto",
|
||||||
Color: "auto",
|
Color: "auto",
|
||||||
Resolve: true,
|
Resolve: true,
|
||||||
@@ -154,7 +156,9 @@ func (c *Config) GetInterval() time.Duration {
|
|||||||
|
|
||||||
// CreateExampleConfig creates an example configuration file
|
// CreateExampleConfig creates an example configuration file
|
||||||
func CreateExampleConfig(path string) error {
|
func CreateExampleConfig(path string) error {
|
||||||
exampleConfig := `# snitch configuration file
|
themeList := strings.Join(theme.ListThemes(), ", ")
|
||||||
|
|
||||||
|
exampleConfig := fmt.Sprintf(`# snitch configuration file
|
||||||
# See https://github.com/you/snitch for full documentation
|
# See https://github.com/you/snitch for full documentation
|
||||||
|
|
||||||
[defaults]
|
[defaults]
|
||||||
@@ -167,8 +171,9 @@ numeric = false
|
|||||||
# Default fields to display (comma-separated list)
|
# Default fields to display (comma-separated list)
|
||||||
fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"]
|
fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"]
|
||||||
|
|
||||||
# Default theme for TUI (dark, light, mono, auto)
|
# Default theme for TUI (ansi inherits terminal colors)
|
||||||
theme = "auto"
|
# Available: %s
|
||||||
|
theme = "%s"
|
||||||
|
|
||||||
# Default units for byte display (auto, si, iec)
|
# Default units for byte display (auto, si, iec)
|
||||||
units = "auto"
|
units = "auto"
|
||||||
@@ -187,7 +192,7 @@ ipv6 = false
|
|||||||
no_headers = false
|
no_headers = false
|
||||||
output_format = "table"
|
output_format = "table"
|
||||||
sort_by = ""
|
sort_by = ""
|
||||||
`
|
`, themeList, theme.DefaultTheme)
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package resolver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
|
||||||
|
|
||||||
// Resolver handles DNS and service name resolution with caching and timeouts
|
// Resolver handles DNS and service name resolution with caching and timeouts
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
@@ -41,6 +45,7 @@ func (r *Resolver) ResolveAddr(addr string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform resolution with timeout
|
// Perform resolution with timeout
|
||||||
|
start := time.Now()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -55,6 +60,11 @@ func (r *Resolver) ResolveAddr(addr string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if debugTiming && elapsed > 50*time.Millisecond {
|
||||||
|
fmt.Fprintf(os.Stderr, "[timing] slow DNS lookup: %s -> %s (%v)\n", addr, resolved, elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
r.cache[addr] = resolved
|
r.cache[addr] = resolved
|
||||||
|
|||||||
24
internal/theme/ansi.go
Normal file
24
internal/theme/ansi.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// ANSI palette uses standard terminal colors (0-15)
|
||||||
|
// this allows the theme to inherit from the user's terminal color scheme
|
||||||
|
var paletteANSI = Palette{
|
||||||
|
Name: "ansi",
|
||||||
|
|
||||||
|
Fg: "15", // bright white
|
||||||
|
FgMuted: "7", // white
|
||||||
|
FgSubtle: "8", // bright black (gray)
|
||||||
|
Bg: "0", // black
|
||||||
|
BgMuted: "0", // black
|
||||||
|
Border: "8", // bright black (gray)
|
||||||
|
|
||||||
|
Red: "1", // red
|
||||||
|
Green: "2", // green
|
||||||
|
Yellow: "3", // yellow
|
||||||
|
Blue: "4", // blue
|
||||||
|
Magenta: "5", // magenta
|
||||||
|
Cyan: "6", // cyan
|
||||||
|
Orange: "3", // yellow (ansi has no orange, fallback to yellow)
|
||||||
|
Gray: "8", // bright black
|
||||||
|
}
|
||||||
|
|
||||||
87
internal/theme/catppuccin.go
Normal file
87
internal/theme/catppuccin.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// catppuccin mocha (dark)
|
||||||
|
// https://github.com/catppuccin/catppuccin
|
||||||
|
var paletteCatppuccinMocha = Palette{
|
||||||
|
Name: "catppuccin-mocha",
|
||||||
|
|
||||||
|
Fg: "#cdd6f4", // text
|
||||||
|
FgMuted: "#a6adc8", // subtext0
|
||||||
|
FgSubtle: "#6c7086", // overlay0
|
||||||
|
Bg: "#1e1e2e", // base
|
||||||
|
BgMuted: "#313244", // surface0
|
||||||
|
Border: "#45475a", // surface1
|
||||||
|
|
||||||
|
Red: "#f38ba8",
|
||||||
|
Green: "#a6e3a1",
|
||||||
|
Yellow: "#f9e2af",
|
||||||
|
Blue: "#89b4fa",
|
||||||
|
Magenta: "#cba6f7", // mauve
|
||||||
|
Cyan: "#94e2d5", // teal
|
||||||
|
Orange: "#fab387", // peach
|
||||||
|
Gray: "#585b70", // surface2
|
||||||
|
}
|
||||||
|
|
||||||
|
// catppuccin macchiato (medium-dark)
|
||||||
|
var paletteCatppuccinMacchiato = Palette{
|
||||||
|
Name: "catppuccin-macchiato",
|
||||||
|
|
||||||
|
Fg: "#cad3f5", // text
|
||||||
|
FgMuted: "#a5adcb", // subtext0
|
||||||
|
FgSubtle: "#6e738d", // overlay0
|
||||||
|
Bg: "#24273a", // base
|
||||||
|
BgMuted: "#363a4f", // surface0
|
||||||
|
Border: "#494d64", // surface1
|
||||||
|
|
||||||
|
Red: "#ed8796",
|
||||||
|
Green: "#a6da95",
|
||||||
|
Yellow: "#eed49f",
|
||||||
|
Blue: "#8aadf4",
|
||||||
|
Magenta: "#c6a0f6", // mauve
|
||||||
|
Cyan: "#8bd5ca", // teal
|
||||||
|
Orange: "#f5a97f", // peach
|
||||||
|
Gray: "#5b6078", // surface2
|
||||||
|
}
|
||||||
|
|
||||||
|
// catppuccin frappe (medium)
|
||||||
|
var paletteCatppuccinFrappe = Palette{
|
||||||
|
Name: "catppuccin-frappe",
|
||||||
|
|
||||||
|
Fg: "#c6d0f5", // text
|
||||||
|
FgMuted: "#a5adce", // subtext0
|
||||||
|
FgSubtle: "#737994", // overlay0
|
||||||
|
Bg: "#303446", // base
|
||||||
|
BgMuted: "#414559", // surface0
|
||||||
|
Border: "#51576d", // surface1
|
||||||
|
|
||||||
|
Red: "#e78284",
|
||||||
|
Green: "#a6d189",
|
||||||
|
Yellow: "#e5c890",
|
||||||
|
Blue: "#8caaee",
|
||||||
|
Magenta: "#ca9ee6", // mauve
|
||||||
|
Cyan: "#81c8be", // teal
|
||||||
|
Orange: "#ef9f76", // peach
|
||||||
|
Gray: "#626880", // surface2
|
||||||
|
}
|
||||||
|
|
||||||
|
// catppuccin latte (light)
|
||||||
|
var paletteCatppuccinLatte = Palette{
|
||||||
|
Name: "catppuccin-latte",
|
||||||
|
|
||||||
|
Fg: "#4c4f69", // text
|
||||||
|
FgMuted: "#6c6f85", // subtext0
|
||||||
|
FgSubtle: "#9ca0b0", // overlay0
|
||||||
|
Bg: "#eff1f5", // base
|
||||||
|
BgMuted: "#ccd0da", // surface0
|
||||||
|
Border: "#bcc0cc", // surface1
|
||||||
|
|
||||||
|
Red: "#d20f39",
|
||||||
|
Green: "#40a02b",
|
||||||
|
Yellow: "#df8e1d",
|
||||||
|
Blue: "#1e66f5",
|
||||||
|
Magenta: "#8839ef", // mauve
|
||||||
|
Cyan: "#179299", // teal
|
||||||
|
Orange: "#fe640b", // peach
|
||||||
|
Gray: "#acb0be", // surface2
|
||||||
|
}
|
||||||
|
|
||||||
24
internal/theme/dracula.go
Normal file
24
internal/theme/dracula.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// dracula theme
|
||||||
|
// https://draculatheme.com/
|
||||||
|
var paletteDracula = Palette{
|
||||||
|
Name: "dracula",
|
||||||
|
|
||||||
|
Fg: "#f8f8f2", // foreground
|
||||||
|
FgMuted: "#f8f8f2", // foreground
|
||||||
|
FgSubtle: "#6272a4", // comment
|
||||||
|
Bg: "#282a36", // background
|
||||||
|
BgMuted: "#44475a", // selection
|
||||||
|
Border: "#44475a", // selection
|
||||||
|
|
||||||
|
Red: "#ff5555",
|
||||||
|
Green: "#50fa7b",
|
||||||
|
Yellow: "#f1fa8c",
|
||||||
|
Blue: "#6272a4", // dracula uses comment color for blue tones
|
||||||
|
Magenta: "#bd93f9", // purple
|
||||||
|
Cyan: "#8be9fd",
|
||||||
|
Orange: "#ffb86c",
|
||||||
|
Gray: "#6272a4", // comment
|
||||||
|
}
|
||||||
|
|
||||||
45
internal/theme/gruvbox.go
Normal file
45
internal/theme/gruvbox.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// gruvbox dark
|
||||||
|
// https://github.com/morhetz/gruvbox
|
||||||
|
var paletteGruvboxDark = Palette{
|
||||||
|
Name: "gruvbox-dark",
|
||||||
|
|
||||||
|
Fg: "#ebdbb2", // fg
|
||||||
|
FgMuted: "#d5c4a1", // fg2
|
||||||
|
FgSubtle: "#a89984", // fg4
|
||||||
|
Bg: "#282828", // bg
|
||||||
|
BgMuted: "#3c3836", // bg1
|
||||||
|
Border: "#504945", // bg2
|
||||||
|
|
||||||
|
Red: "#fb4934",
|
||||||
|
Green: "#b8bb26",
|
||||||
|
Yellow: "#fabd2f",
|
||||||
|
Blue: "#83a598",
|
||||||
|
Magenta: "#d3869b", // purple
|
||||||
|
Cyan: "#8ec07c", // aqua
|
||||||
|
Orange: "#fe8019",
|
||||||
|
Gray: "#928374",
|
||||||
|
}
|
||||||
|
|
||||||
|
// gruvbox light
|
||||||
|
var paletteGruvboxLight = Palette{
|
||||||
|
Name: "gruvbox-light",
|
||||||
|
|
||||||
|
Fg: "#3c3836", // fg
|
||||||
|
FgMuted: "#504945", // fg2
|
||||||
|
FgSubtle: "#7c6f64", // fg4
|
||||||
|
Bg: "#fbf1c7", // bg
|
||||||
|
BgMuted: "#ebdbb2", // bg1
|
||||||
|
Border: "#d5c4a1", // bg2
|
||||||
|
|
||||||
|
Red: "#cc241d",
|
||||||
|
Green: "#98971a",
|
||||||
|
Yellow: "#d79921",
|
||||||
|
Blue: "#458588",
|
||||||
|
Magenta: "#b16286", // purple
|
||||||
|
Cyan: "#689d6a", // aqua
|
||||||
|
Orange: "#d65d0e",
|
||||||
|
Gray: "#928374",
|
||||||
|
}
|
||||||
|
|
||||||
49
internal/theme/mono.go
Normal file
49
internal/theme/mono.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// createMonoTheme creates a monochrome theme (no colors)
|
||||||
|
// useful for accessibility, piping output, or minimal terminals
|
||||||
|
func createMonoTheme() *Theme {
|
||||||
|
baseStyle := lipgloss.NewStyle()
|
||||||
|
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
|
||||||
|
return &Theme{
|
||||||
|
Name: "mono",
|
||||||
|
Styles: Styles{
|
||||||
|
Header: boldStyle,
|
||||||
|
Border: baseStyle,
|
||||||
|
Selected: boldStyle,
|
||||||
|
Watched: boldStyle,
|
||||||
|
Normal: baseStyle,
|
||||||
|
Error: boldStyle,
|
||||||
|
Success: boldStyle,
|
||||||
|
Warning: boldStyle,
|
||||||
|
Footer: baseStyle,
|
||||||
|
Background: baseStyle,
|
||||||
|
|
||||||
|
Proto: ProtoStyles{
|
||||||
|
TCP: baseStyle,
|
||||||
|
UDP: baseStyle,
|
||||||
|
Unix: baseStyle,
|
||||||
|
TCP6: baseStyle,
|
||||||
|
UDP6: baseStyle,
|
||||||
|
},
|
||||||
|
|
||||||
|
State: StateStyles{
|
||||||
|
Listen: baseStyle,
|
||||||
|
Established: baseStyle,
|
||||||
|
TimeWait: baseStyle,
|
||||||
|
CloseWait: baseStyle,
|
||||||
|
SynSent: baseStyle,
|
||||||
|
SynRecv: baseStyle,
|
||||||
|
FinWait1: baseStyle,
|
||||||
|
FinWait2: baseStyle,
|
||||||
|
Closing: baseStyle,
|
||||||
|
LastAck: baseStyle,
|
||||||
|
Closed: baseStyle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
internal/theme/nord.go
Normal file
24
internal/theme/nord.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// nord theme
|
||||||
|
// https://www.nordtheme.com/
|
||||||
|
var paletteNord = Palette{
|
||||||
|
Name: "nord",
|
||||||
|
|
||||||
|
Fg: "#eceff4", // snow storm - nord6
|
||||||
|
FgMuted: "#d8dee9", // snow storm - nord4
|
||||||
|
FgSubtle: "#4c566a", // polar night - nord3
|
||||||
|
Bg: "#2e3440", // polar night - nord0
|
||||||
|
BgMuted: "#3b4252", // polar night - nord1
|
||||||
|
Border: "#434c5e", // polar night - nord2
|
||||||
|
|
||||||
|
Red: "#bf616a", // aurora - nord11
|
||||||
|
Green: "#a3be8c", // aurora - nord14
|
||||||
|
Yellow: "#ebcb8b", // aurora - nord13
|
||||||
|
Blue: "#81a1c1", // frost - nord9
|
||||||
|
Magenta: "#b48ead", // aurora - nord15
|
||||||
|
Cyan: "#88c0d0", // frost - nord8
|
||||||
|
Orange: "#d08770", // aurora - nord12
|
||||||
|
Gray: "#4c566a", // polar night - nord3
|
||||||
|
}
|
||||||
|
|
||||||
24
internal/theme/one_dark.go
Normal file
24
internal/theme/one_dark.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// one dark theme (atom editor)
|
||||||
|
// https://github.com/atom/atom/tree/master/packages/one-dark-syntax
|
||||||
|
var paletteOneDark = Palette{
|
||||||
|
Name: "one-dark",
|
||||||
|
|
||||||
|
Fg: "#abb2bf", // foreground
|
||||||
|
FgMuted: "#9da5b4", // foreground muted
|
||||||
|
FgSubtle: "#5c6370", // comment
|
||||||
|
Bg: "#282c34", // background
|
||||||
|
BgMuted: "#21252b", // gutter background
|
||||||
|
Border: "#3e4451", // selection
|
||||||
|
|
||||||
|
Red: "#e06c75",
|
||||||
|
Green: "#98c379",
|
||||||
|
Yellow: "#e5c07b",
|
||||||
|
Blue: "#61afef",
|
||||||
|
Magenta: "#c678dd", // purple
|
||||||
|
Cyan: "#56b6c2",
|
||||||
|
Orange: "#d19a66",
|
||||||
|
Gray: "#5c6370", // comment
|
||||||
|
}
|
||||||
|
|
||||||
111
internal/theme/palette.go
Normal file
111
internal/theme/palette.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Palette defines the semantic colors for a theme
|
||||||
|
type Palette struct {
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// base colors
|
||||||
|
Fg string // primary foreground
|
||||||
|
FgMuted string // secondary/muted foreground
|
||||||
|
FgSubtle string // subtle/disabled foreground
|
||||||
|
Bg string // primary background
|
||||||
|
BgMuted string // secondary background (selections, highlights)
|
||||||
|
Border string // border color
|
||||||
|
|
||||||
|
// semantic colors
|
||||||
|
Red string
|
||||||
|
Green string
|
||||||
|
Yellow string
|
||||||
|
Blue string
|
||||||
|
Magenta string
|
||||||
|
Cyan string
|
||||||
|
Orange string
|
||||||
|
Gray string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color converts a palette color string to a lipgloss.TerminalColor.
|
||||||
|
// If the string is 1-2 characters, it's treated as an ANSI color code.
|
||||||
|
// Otherwise, it's treated as a hex color.
|
||||||
|
func (p *Palette) Color(c string) lipgloss.TerminalColor {
|
||||||
|
if c == "" {
|
||||||
|
return lipgloss.NoColor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c) <= 2 {
|
||||||
|
n, err := strconv.Atoi(c)
|
||||||
|
if err == nil {
|
||||||
|
return lipgloss.ANSIColor(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.Color(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToTheme converts a Palette to a Theme with lipgloss styles
|
||||||
|
func (p *Palette) ToTheme() *Theme {
|
||||||
|
return &Theme{
|
||||||
|
Name: p.Name,
|
||||||
|
Styles: Styles{
|
||||||
|
Header: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(p.Color(p.Fg)),
|
||||||
|
|
||||||
|
Border: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.Border)),
|
||||||
|
|
||||||
|
Selected: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(p.Color(p.Fg)),
|
||||||
|
|
||||||
|
Watched: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(p.Color(p.Orange)),
|
||||||
|
|
||||||
|
Normal: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.FgMuted)),
|
||||||
|
|
||||||
|
Error: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.Red)),
|
||||||
|
|
||||||
|
Success: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.Green)),
|
||||||
|
|
||||||
|
Warning: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.Yellow)),
|
||||||
|
|
||||||
|
Footer: lipgloss.NewStyle().
|
||||||
|
Foreground(p.Color(p.FgSubtle)),
|
||||||
|
|
||||||
|
Background: lipgloss.NewStyle(),
|
||||||
|
|
||||||
|
Proto: ProtoStyles{
|
||||||
|
TCP: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
|
||||||
|
UDP: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
|
||||||
|
Unix: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
|
||||||
|
TCP6: lipgloss.NewStyle().Foreground(p.Color(p.Cyan)),
|
||||||
|
UDP6: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
|
||||||
|
},
|
||||||
|
|
||||||
|
State: StateStyles{
|
||||||
|
Listen: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
|
||||||
|
Established: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
|
||||||
|
TimeWait: lipgloss.NewStyle().Foreground(p.Color(p.Yellow)),
|
||||||
|
CloseWait: lipgloss.NewStyle().Foreground(p.Color(p.Orange)),
|
||||||
|
SynSent: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
|
||||||
|
SynRecv: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
|
||||||
|
FinWait1: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
|
||||||
|
FinWait2: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
|
||||||
|
Closing: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
|
||||||
|
LastAck: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
|
||||||
|
Closed: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
14
internal/theme/readme.md
Normal file
14
internal/theme/readme.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# theme Palettes
|
||||||
|
|
||||||
|
the color palettes in this directory were generated by an LLM agent (Claude Opus 4.5) using web search to fetch the official color specifications from each themes documentation
|
||||||
|
as it is with llm agents its possible the colors may be wrong
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- [Catppuccin](https://github.com/catppuccin/catppuccin)
|
||||||
|
- [Dracula](https://draculatheme.com/)
|
||||||
|
- [Gruvbox](https://github.com/morhetz/gruvbox)
|
||||||
|
- [Nord](https://www.nordtheme.com/)
|
||||||
|
- [One Dark](https://github.com/atom/one-dark-syntax)
|
||||||
|
- [Solarized](https://ethanschoonover.com/solarized/)
|
||||||
|
- [Tokyo Night](https://github.com/enkia/tokyo-night-vscode-theme)
|
||||||
|
|
||||||
45
internal/theme/solarized.go
Normal file
45
internal/theme/solarized.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// solarized dark theme
|
||||||
|
// https://ethanschoonover.com/solarized/
|
||||||
|
var paletteSolarizedDark = Palette{
|
||||||
|
Name: "solarized-dark",
|
||||||
|
|
||||||
|
Fg: "#839496", // base0
|
||||||
|
FgMuted: "#93a1a1", // base1
|
||||||
|
FgSubtle: "#586e75", // base01
|
||||||
|
Bg: "#002b36", // base03
|
||||||
|
BgMuted: "#073642", // base02
|
||||||
|
Border: "#073642", // base02
|
||||||
|
|
||||||
|
Red: "#dc322f",
|
||||||
|
Green: "#859900",
|
||||||
|
Yellow: "#b58900",
|
||||||
|
Blue: "#268bd2",
|
||||||
|
Magenta: "#d33682",
|
||||||
|
Cyan: "#2aa198",
|
||||||
|
Orange: "#cb4b16",
|
||||||
|
Gray: "#657b83", // base00
|
||||||
|
}
|
||||||
|
|
||||||
|
// solarized light theme
|
||||||
|
var paletteSolarizedLight = Palette{
|
||||||
|
Name: "solarized-light",
|
||||||
|
|
||||||
|
Fg: "#657b83", // base00
|
||||||
|
FgMuted: "#586e75", // base01
|
||||||
|
FgSubtle: "#93a1a1", // base1
|
||||||
|
Bg: "#fdf6e3", // base3
|
||||||
|
BgMuted: "#eee8d5", // base2
|
||||||
|
Border: "#eee8d5", // base2
|
||||||
|
|
||||||
|
Red: "#dc322f",
|
||||||
|
Green: "#859900",
|
||||||
|
Yellow: "#b58900",
|
||||||
|
Blue: "#268bd2",
|
||||||
|
Magenta: "#d33682",
|
||||||
|
Cyan: "#2aa198",
|
||||||
|
Orange: "#cb4b16",
|
||||||
|
Gray: "#839496", // base0
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package theme
|
package theme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -52,152 +53,73 @@ type StateStyles struct {
|
|||||||
Closed lipgloss.Style
|
Closed lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var themes map[string]*Theme
|
||||||
themes map[string]*Theme
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
themes = map[string]*Theme{
|
themes = make(map[string]*Theme)
|
||||||
"default": createAdaptiveTheme(),
|
|
||||||
"mono": createMonoTheme(),
|
// ansi theme (default) - inherits from terminal colors
|
||||||
}
|
themes["ansi"] = paletteANSI.ToTheme()
|
||||||
|
|
||||||
|
// catppuccin variants
|
||||||
|
themes["catppuccin-mocha"] = paletteCatppuccinMocha.ToTheme()
|
||||||
|
themes["catppuccin-macchiato"] = paletteCatppuccinMacchiato.ToTheme()
|
||||||
|
themes["catppuccin-frappe"] = paletteCatppuccinFrappe.ToTheme()
|
||||||
|
themes["catppuccin-latte"] = paletteCatppuccinLatte.ToTheme()
|
||||||
|
|
||||||
|
// gruvbox variants
|
||||||
|
themes["gruvbox-dark"] = paletteGruvboxDark.ToTheme()
|
||||||
|
themes["gruvbox-light"] = paletteGruvboxLight.ToTheme()
|
||||||
|
|
||||||
|
// dracula
|
||||||
|
themes["dracula"] = paletteDracula.ToTheme()
|
||||||
|
|
||||||
|
// nord
|
||||||
|
themes["nord"] = paletteNord.ToTheme()
|
||||||
|
|
||||||
|
// tokyo night variants
|
||||||
|
themes["tokyo-night"] = paletteTokyoNight.ToTheme()
|
||||||
|
themes["tokyo-night-storm"] = paletteTokyoNightStorm.ToTheme()
|
||||||
|
themes["tokyo-night-light"] = paletteTokyoNightLight.ToTheme()
|
||||||
|
|
||||||
|
// solarized variants
|
||||||
|
themes["solarized-dark"] = paletteSolarizedDark.ToTheme()
|
||||||
|
themes["solarized-light"] = paletteSolarizedLight.ToTheme()
|
||||||
|
|
||||||
|
// one dark
|
||||||
|
themes["one-dark"] = paletteOneDark.ToTheme()
|
||||||
|
|
||||||
|
// monochrome (no colors)
|
||||||
|
themes["mono"] = createMonoTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTheme returns a theme by name, with auto-detection support
|
// DefaultTheme is the theme used when none is specified
|
||||||
|
const DefaultTheme = "ansi"
|
||||||
|
|
||||||
|
// GetTheme returns a theme by name
|
||||||
func GetTheme(name string) *Theme {
|
func GetTheme(name string) *Theme {
|
||||||
if name == "auto" {
|
if name == "" || name == "auto" || name == "default" {
|
||||||
// lipgloss handles adaptive colors, so we just return the default
|
return themes[DefaultTheme]
|
||||||
return themes["default"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if theme, exists := themes[name]; exists {
|
if theme, exists := themes[name]; exists {
|
||||||
return theme
|
return theme
|
||||||
}
|
}
|
||||||
|
|
||||||
// a specific theme was requested (e.g. "dark", "light"), but we now use adaptive
|
|
||||||
// so we can just return the default theme and lipgloss will handle it
|
|
||||||
if name == "dark" || name == "light" {
|
|
||||||
return themes["default"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback to default
|
// fallback to default
|
||||||
return themes["default"]
|
return themes[DefaultTheme]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListThemes returns available theme names
|
// ListThemes returns available theme names sorted alphabetically
|
||||||
func ListThemes() []string {
|
func ListThemes() []string {
|
||||||
var names []string
|
names := make([]string, 0, len(themes))
|
||||||
for name := range themes {
|
for name := range themes {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
sort.Strings(names)
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAdaptiveTheme creates a clean, minimal theme
|
|
||||||
func createAdaptiveTheme() *Theme {
|
|
||||||
return &Theme{
|
|
||||||
Name: "default",
|
|
||||||
Styles: Styles{
|
|
||||||
Header: lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
|
|
||||||
|
|
||||||
Watched: lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}),
|
|
||||||
|
|
||||||
Border: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"}),
|
|
||||||
|
|
||||||
Selected: lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
|
|
||||||
|
|
||||||
Normal: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
|
|
||||||
|
|
||||||
Error: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
|
|
||||||
|
|
||||||
Success: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
|
|
||||||
|
|
||||||
Warning: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
|
|
||||||
|
|
||||||
Footer: lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
|
|
||||||
|
|
||||||
Background: lipgloss.NewStyle(),
|
|
||||||
|
|
||||||
Proto: ProtoStyles{
|
|
||||||
TCP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
|
|
||||||
UDP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
|
|
||||||
Unix: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
|
|
||||||
TCP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
|
|
||||||
UDP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
|
|
||||||
},
|
|
||||||
|
|
||||||
State: StateStyles{
|
|
||||||
Listen: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
|
|
||||||
Established: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2563EB", Dark: "#60A5FA"}),
|
|
||||||
TimeWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
|
|
||||||
CloseWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
|
|
||||||
SynSent: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
|
|
||||||
SynRecv: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
|
|
||||||
FinWait1: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
|
|
||||||
FinWait2: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
|
|
||||||
Closing: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
|
|
||||||
LastAck: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
|
|
||||||
Closed: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createMonoTheme creates a monochrome theme (no colors)
|
|
||||||
func createMonoTheme() *Theme {
|
|
||||||
baseStyle := lipgloss.NewStyle()
|
|
||||||
boldStyle := lipgloss.NewStyle().Bold(true)
|
|
||||||
|
|
||||||
return &Theme{
|
|
||||||
Name: "mono",
|
|
||||||
Styles: Styles{
|
|
||||||
Header: boldStyle,
|
|
||||||
Border: baseStyle,
|
|
||||||
Selected: boldStyle,
|
|
||||||
Normal: baseStyle,
|
|
||||||
Error: boldStyle,
|
|
||||||
Success: boldStyle,
|
|
||||||
Warning: boldStyle,
|
|
||||||
Footer: baseStyle,
|
|
||||||
Background: baseStyle,
|
|
||||||
|
|
||||||
Proto: ProtoStyles{
|
|
||||||
TCP: baseStyle,
|
|
||||||
UDP: baseStyle,
|
|
||||||
Unix: baseStyle,
|
|
||||||
TCP6: baseStyle,
|
|
||||||
UDP6: baseStyle,
|
|
||||||
},
|
|
||||||
|
|
||||||
State: StateStyles{
|
|
||||||
Listen: baseStyle,
|
|
||||||
Established: baseStyle,
|
|
||||||
TimeWait: baseStyle,
|
|
||||||
CloseWait: baseStyle,
|
|
||||||
SynSent: baseStyle,
|
|
||||||
SynRecv: baseStyle,
|
|
||||||
FinWait1: baseStyle,
|
|
||||||
FinWait2: baseStyle,
|
|
||||||
Closing: baseStyle,
|
|
||||||
LastAck: baseStyle,
|
|
||||||
Closed: baseStyle,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProtoStyle returns the appropriate style for a protocol
|
// GetProtoStyle returns the appropriate style for a protocol
|
||||||
func (s *Styles) GetProtoStyle(proto string) lipgloss.Style {
|
func (s *Styles) GetProtoStyle(proto string) lipgloss.Style {
|
||||||
switch strings.ToLower(proto) {
|
switch strings.ToLower(proto) {
|
||||||
|
|||||||
66
internal/theme/tokyo_night.go
Normal file
66
internal/theme/tokyo_night.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
// tokyo night theme
|
||||||
|
// https://github.com/enkia/tokyo-night-vscode-theme
|
||||||
|
var paletteTokyoNight = Palette{
|
||||||
|
Name: "tokyo-night",
|
||||||
|
|
||||||
|
Fg: "#c0caf5", // foreground
|
||||||
|
FgMuted: "#a9b1d6", // foreground dark
|
||||||
|
FgSubtle: "#565f89", // comment
|
||||||
|
Bg: "#1a1b26", // background
|
||||||
|
BgMuted: "#24283b", // background highlight
|
||||||
|
Border: "#414868", // border
|
||||||
|
|
||||||
|
Red: "#f7768e",
|
||||||
|
Green: "#9ece6a",
|
||||||
|
Yellow: "#e0af68",
|
||||||
|
Blue: "#7aa2f7",
|
||||||
|
Magenta: "#bb9af7", // purple
|
||||||
|
Cyan: "#7dcfff",
|
||||||
|
Orange: "#ff9e64",
|
||||||
|
Gray: "#565f89", // comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokyo night storm variant
|
||||||
|
var paletteTokyoNightStorm = Palette{
|
||||||
|
Name: "tokyo-night-storm",
|
||||||
|
|
||||||
|
Fg: "#c0caf5", // foreground
|
||||||
|
FgMuted: "#a9b1d6", // foreground dark
|
||||||
|
FgSubtle: "#565f89", // comment
|
||||||
|
Bg: "#24283b", // background (storm is slightly lighter)
|
||||||
|
BgMuted: "#1f2335", // background dark
|
||||||
|
Border: "#414868", // border
|
||||||
|
|
||||||
|
Red: "#f7768e",
|
||||||
|
Green: "#9ece6a",
|
||||||
|
Yellow: "#e0af68",
|
||||||
|
Blue: "#7aa2f7",
|
||||||
|
Magenta: "#bb9af7", // purple
|
||||||
|
Cyan: "#7dcfff",
|
||||||
|
Orange: "#ff9e64",
|
||||||
|
Gray: "#565f89", // comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokyo night light variant
|
||||||
|
var paletteTokyoNightLight = Palette{
|
||||||
|
Name: "tokyo-night-light",
|
||||||
|
|
||||||
|
Fg: "#343b58", // foreground
|
||||||
|
FgMuted: "#565a6e", // foreground dark
|
||||||
|
FgSubtle: "#9699a3", // comment
|
||||||
|
Bg: "#d5d6db", // background
|
||||||
|
BgMuted: "#cbccd1", // background highlight
|
||||||
|
Border: "#b4b5b9", // border
|
||||||
|
|
||||||
|
Red: "#8c4351",
|
||||||
|
Green: "#485e30",
|
||||||
|
Yellow: "#8f5e15",
|
||||||
|
Blue: "#34548a",
|
||||||
|
Magenta: "#5a4a78", // purple
|
||||||
|
Cyan: "#0f4b6e",
|
||||||
|
Orange: "#965027",
|
||||||
|
Gray: "#9699a3", // comment
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user