roman.nikolsky vor 1 Tag
Commit
00ef1ce7a2

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+*.log
+plan.md
+context.md

+ 26 - 0
go.mod

@@ -0,0 +1,26 @@
+module fbtop
+
+go 1.25.0
+
+require (
+	charm.land/bubbletea/v2 v2.0.9
+	charm.land/lipgloss/v2 v2.0.6
+)
+
+require (
+	github.com/charmbracelet/colorprofile v0.4.3 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
+	github.com/charmbracelet/x/ansi v0.11.8 // indirect
+	github.com/charmbracelet/x/term v0.2.2 // indirect
+	github.com/charmbracelet/x/termios v0.1.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/clipperhouse/displaywidth v0.11.0 // indirect
+	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
+	github.com/mattn/go-runewidth v0.0.24 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/sync v0.22.0 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+)

+ 40 - 0
go.sum

@@ -0,0 +1,40 @@
+charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
+charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
+charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
+charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

+ 46 - 0
internal/api/client.go

@@ -0,0 +1,46 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"sort"
+	"time"
+
+	"fbtop/internal/shared"
+)
+
+func Fetch(url string) (shared.State, error) {
+	resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(url + "/api/v1/metrics")
+	if err != nil {
+		return shared.State{}, err
+	}
+	defer resp.Body.Close()
+
+	var raw map[string]map[string]map[string]int64
+	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
+		return shared.State{}, err
+	}
+
+	kinds := map[string]shared.Kind{
+		"input":  shared.KindInput,
+		"filter": shared.KindFilter,
+		"output": shared.KindOutput,
+	}
+
+	now := time.Now()
+	var cs []shared.ComponentMetrics
+	for section, comps := range raw {
+		k, ok := kinds[section]
+		if !ok {
+			continue
+		}
+		for id, fields := range comps {
+			cs = append(cs, shared.ComponentMetrics{ID: id, Kind: k, Fields: fields, Stamp: now})
+		}
+	}
+
+	// Stable sort by kind for consistent display order
+	sort.SliceStable(cs, func(i, j int) bool { return cs[i].Kind < cs[j].Kind })
+
+	return shared.State{Comps: cs, Connected: true, UpdatedAt: now}, nil
+}

+ 164 - 0
internal/app/model.go

@@ -0,0 +1,164 @@
+package app
+
+import (
+	"fmt"
+	"time"
+
+	"fbtop/internal/api"
+	"fbtop/internal/detail"
+	"fbtop/internal/footer"
+	"fbtop/internal/header"
+	"fbtop/internal/shared"
+	"fbtop/internal/tableview"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+)
+
+type tickMsg struct{}
+type errMsg struct{ err error }
+
+type Model struct {
+	state      shared.State
+	url        string
+	interval   time.Duration
+	width      int
+	height     int
+	cursor     int
+	humanize   bool
+	showDetail bool
+	sortCol    shared.SortCol
+	sortAsc    bool
+	filterKind shared.Kind
+	err        error
+}
+
+func New(url string, interval time.Duration, humanize bool, sortCol shared.SortCol, sortAsc bool, filterKind shared.Kind) Model {
+	return Model{
+		url:        url,
+		interval:   interval,
+		humanize:   humanize,
+		sortCol:    sortCol,
+		sortAsc:    sortAsc,
+		filterKind: filterKind,
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return tea.Batch(m.fetch(), tick(m.interval))
+}
+
+func tick(d time.Duration) tea.Cmd {
+	return func() tea.Msg {
+		time.Sleep(d)
+		return tickMsg{}
+	}
+}
+
+func (m Model) fetch() tea.Cmd {
+	prev := m.state.Comps
+	return func() tea.Msg {
+		s, err := api.Fetch(m.url)
+		if err != nil {
+			return errMsg{err}
+		}
+		s.PrevComps = prev
+		return s
+	}
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+	case shared.State:
+		m.state = msg
+		m.err = nil
+		return m, tick(m.interval)
+	case errMsg:
+		m.err = msg.err
+		m.state.Connected = false
+		return m, tick(m.interval)
+	case tickMsg:
+		return m, m.fetch()
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "q", "ctrl+c":
+			return m, tea.Quit
+		case "up", "k":
+			if m.cursor > 0 {
+				m.cursor--
+			}
+		case "down", "j":
+			if m.cursor < len(m.filteredAndSorted())-1 {
+				m.cursor++
+			}
+		case "tab": // cycle filter: all→input→filter→output
+			m.filterKind = (m.filterKind + 1) % 4
+			m.cursor = 0
+		case "s":
+			m.sortCol = m.sortCol.Next()
+		case "S":
+			m.sortAsc = !m.sortAsc
+		case "h":
+			m.humanize = !m.humanize
+		case "enter":
+			m.showDetail = !m.showDetail
+		case "esc":
+			m.showDetail = false
+		}
+	}
+	return m, nil
+}
+
+func (m Model) View() tea.View {
+	v := tea.NewView("")
+	v.AltScreen = true
+
+	if m.width == 0 {
+		v.SetContent("Initializing...")
+		return v
+	}
+	if m.err != nil {
+		v.SetContent(fmt.Sprintf("Error: %v\n\nPress [q] to quit.", m.err))
+		return v
+	}
+
+	comps := m.filteredAndSorted()
+
+	parts := []string{
+		header.Render(m.state, m.url, m.width),
+		tableview.Render(tableview.Params{
+			Comps:     comps,
+			PrevComps: m.state.PrevComps,
+			Width:     m.width,
+			Cursor:    m.cursor,
+			Humanize:  m.humanize,
+		}),
+	}
+
+	if m.showDetail && len(comps) > 0 {
+		idx := m.cursor
+		if idx >= len(comps) {
+			idx = len(comps) - 1
+		}
+		parts = append(parts, detail.Render(comps[idx]))
+	}
+
+	parts = append(parts, footer.Render(m.sortCol.String(), m.filterKind.String(), m.humanize, m.width))
+
+	v.SetContent(lipgloss.JoinVertical(lipgloss.Top, parts...))
+	return v
+}
+
+func (m Model) filteredAndSorted() []shared.ComponentMetrics {
+	comps := make([]shared.ComponentMetrics, 0, len(m.state.Comps))
+	for _, c := range m.state.Comps {
+		if m.filterKind == shared.KindAll || c.Kind == m.filterKind {
+			comps = append(comps, c)
+		}
+	}
+	shared.SortBy(comps, m.sortCol, m.sortAsc)
+	return comps
+}

+ 30 - 0
internal/detail/view.go

@@ -0,0 +1,30 @@
+package detail
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"fbtop/internal/shared"
+
+	"charm.land/lipgloss/v2"
+)
+
+var titleStyle = lipgloss.NewStyle().Bold(true)
+
+func Render(c shared.ComponentMetrics) string {
+	var b strings.Builder
+	b.WriteString(fmt.Sprintf("\n %s\n", titleStyle.Render(c.ID+" ("+c.Kind.String()+")")))
+
+	keys := make([]string, 0, len(c.Fields))
+	for k := range c.Fields {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	for _, k := range keys {
+		b.WriteString(fmt.Sprintf("   %-20s %d\n", k, c.Fields[k]))
+	}
+
+	return b.String()
+}

+ 25 - 0
internal/footer/view.go

@@ -0,0 +1,25 @@
+package footer
+
+import (
+	"fmt"
+
+	"charm.land/lipgloss/v2"
+)
+
+var style = lipgloss.NewStyle().Faint(true).PaddingLeft(1).PaddingTop(1)
+
+func Render(sortCol string, filterKind string, humanize bool, width int) string {
+	hum := "on"
+	if !humanize {
+		hum = "off"
+	}
+
+	help := fmt.Sprintf(
+		"[Tab] section: %s | [s] sort: %s | [h] humanize: %s | [Enter] detail | [q] quit",
+		filterKind,
+		sortCol,
+		hum,
+	)
+
+	return style.Width(width).Render(help)
+}

+ 31 - 0
internal/header/view.go

@@ -0,0 +1,31 @@
+package header
+
+import (
+	"fmt"
+
+	"fbtop/internal/shared"
+
+	"charm.land/lipgloss/v2"
+)
+
+var (
+	titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
+	urlStyle   = lipgloss.NewStyle().Faint(true)
+	dotStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+)
+
+func Render(state shared.State, url string, width int) string {
+	dot := "● Disconnected"
+	if state.Connected {
+		dot = dotStyle.Render("● Connected")
+	}
+
+	title := titleStyle.Render("fbtop")
+	urlStr := urlStyle.Render(url)
+	updated := state.UpdatedAt.Format("15:04:05")
+
+	header := fmt.Sprintf(" %s    %s    %s\n Components: %d   Updated: %s",
+		title, urlStr, dot, len(state.Comps), updated)
+
+	return lipgloss.NewStyle().Width(width).Render(header)
+}

+ 52 - 0
internal/shared/format.go

@@ -0,0 +1,52 @@
+package shared
+
+import (
+	"fmt"
+	"time"
+)
+
+func FormatCount(n int64) string {
+	switch {
+	case n >= 1_000_000_000:
+		return fmt.Sprintf("%.1fB", float64(n)/1_000_000_000)
+	case n >= 1_000_000:
+		return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
+	case n >= 1_000:
+		return fmt.Sprintf("%.1fk", float64(n)/1_000)
+	default:
+		return fmt.Sprintf("%d", n)
+	}
+}
+
+func FormatBytes(b int64) string {
+	switch {
+	case b >= 1<<30:
+		return fmt.Sprintf("%.1fGB", float64(b)/(1<<30))
+	case b >= 1<<20:
+		return fmt.Sprintf("%.1fMB", float64(b)/(1<<20))
+	case b >= 1<<10:
+		return fmt.Sprintf("%.1fKB", float64(b)/(1<<10))
+	default:
+		return fmt.Sprintf("%dB", b)
+	}
+}
+
+// Rate computes per-second rate from two counter snapshots.
+func Rate(prev, curr int64, dt time.Duration) float64 {
+	sec := dt.Seconds()
+	if sec <= 0 {
+		return 0
+	}
+	return float64(curr-prev) / sec
+}
+
+// FormatRate formats a per-second rate. For non-byte rates isBytes=false.
+func FormatRate(val float64, humanize, isBytes bool) string {
+	if !humanize {
+		return fmt.Sprintf("%.0f/s", val)
+	}
+	if isBytes {
+		return FormatBytes(int64(val)) + "/s"
+	}
+	return FormatCount(int64(val)) + "/s"
+}

+ 44 - 0
internal/shared/sort.go

@@ -0,0 +1,44 @@
+package shared
+
+import "slices"
+
+type SortCol int8
+
+const (
+	SortID SortCol = iota
+	SortRecords
+	SortBytes
+)
+
+func (s SortCol) Next() SortCol  { return (s + 1) % 3 }
+func (s SortCol) String() string { return [...]string{"id", "records", "bytes"}[s] }
+
+// SortBy sorts components in-place, preserving kind grouping order.
+func SortBy(cs []ComponentMetrics, col SortCol, asc bool) {
+	less := map[SortCol]func(a, b ComponentMetrics) int{
+		SortID:      func(a, b ComponentMetrics) int { return cmp(a.ID, b.ID) },
+		SortRecords: func(a, b ComponentMetrics) int { return cmp(a.Records(), b.Records()) },
+		SortBytes:   func(a, b ComponentMetrics) int { return cmp(a.Bytes(), b.Bytes()) },
+	}[col]
+
+	slices.SortStableFunc(cs, func(a, b ComponentMetrics) int {
+		if a.Kind != b.Kind {
+			return int(a.Kind - b.Kind)
+		}
+		c := less(a, b)
+		if !asc {
+			return -c
+		}
+		return c
+	})
+}
+
+func cmp[T ~string | ~int64](a, b T) int {
+	if a < b {
+		return -1
+	}
+	if a > b {
+		return 1
+	}
+	return 0
+}

+ 42 - 0
internal/shared/state.go

@@ -0,0 +1,42 @@
+package shared
+
+import "time"
+
+type Kind int8
+
+const (
+	KindInput Kind = iota
+	KindFilter
+	KindOutput
+	KindAll // sentinel for filtering
+)
+
+func (k Kind) String() string { return [...]string{"input", "filter", "output", "all"}[k] }
+
+type ComponentMetrics struct {
+	ID     string
+	Kind   Kind
+	Fields map[string]int64
+	Stamp  time.Time
+}
+
+func (c ComponentMetrics) Records() int64 {
+	if c.Kind == KindOutput {
+		return c.Fields["proc_records"]
+	}
+	return c.Fields["records"]
+}
+
+func (c ComponentMetrics) Bytes() int64 {
+	if c.Kind == KindOutput {
+		return c.Fields["proc_bytes"]
+	}
+	return c.Fields["bytes"]
+}
+
+type State struct {
+	Comps     []ComponentMetrics
+	PrevComps []ComponentMetrics // snapshot for rate computation
+	Connected bool
+	UpdatedAt time.Time
+}

+ 98 - 0
internal/tableview/view.go

@@ -0,0 +1,98 @@
+package tableview
+
+import (
+	"fmt"
+	"strings"
+
+	"fbtop/internal/shared"
+
+	"charm.land/lipgloss/v2"
+)
+
+var (
+	headerStyle   = lipgloss.NewStyle().Bold(true)
+	selectedStyle = lipgloss.NewStyle().Reverse(true)
+)
+
+type Params struct {
+	Comps     []shared.ComponentMetrics
+	PrevComps []shared.ComponentMetrics
+	Width     int
+	Cursor    int
+	Humanize  bool
+}
+
+func Render(p Params) string {
+	prevMap := make(map[string]shared.ComponentMetrics, len(p.PrevComps))
+	for _, c := range p.PrevComps {
+		prevMap[c.ID] = c
+	}
+
+	var b strings.Builder
+	b.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-24s %-7s %10s %10s %s", "#", "Component", "Kind", "Rec/s", "Bytes/s", "Extra")))
+	b.WriteString("\n")
+	b.WriteString(strings.Repeat("─", p.Width))
+	b.WriteString("\n")
+
+	var lastKind shared.Kind = -1
+	for i, c := range p.Comps {
+		if c.Kind != lastKind {
+			b.WriteString("\n")
+			b.WriteString(headerStyle.Render(" " + sectionName(c.Kind)))
+			b.WriteString("\n")
+			lastKind = c.Kind
+		}
+
+		// Rates from delta
+		recRate, byteRate := "-", "-"
+		if prev, ok := prevMap[c.ID]; ok {
+			dt := c.Stamp.Sub(prev.Stamp)
+			if dt > 0 {
+				recRate = shared.FormatRate(shared.Rate(prev.Records(), c.Records(), dt), p.Humanize, false)
+				byteRate = shared.FormatRate(shared.Rate(prev.Bytes(), c.Bytes(), dt), p.Humanize, true)
+			}
+		}
+
+		// Extra column
+		extra := "-"
+		switch c.Kind {
+		case shared.KindOutput:
+			var parts []string
+			if e := c.Fields["errors"]; e > 0 {
+				parts = append(parts, fmt.Sprintf("err:%d", e))
+			}
+			if r := c.Fields["retries"]; r > 0 {
+				parts = append(parts, fmt.Sprintf("ret:%d", r))
+			}
+			if len(parts) > 0 {
+				extra = strings.Join(parts, " ")
+			}
+		case shared.KindFilter:
+			if d := c.Fields["drop_records"]; d > 0 {
+				extra = fmt.Sprintf("drop:%d", d)
+			}
+		}
+
+		row := fmt.Sprintf(" %-4d %-24s %-7s %10s %10s %s", i+1, c.ID, c.Kind, recRate, byteRate, extra)
+		if i == p.Cursor {
+			row = selectedStyle.Render(row)
+		}
+		b.WriteString(row)
+		b.WriteString("\n")
+	}
+
+	return b.String()
+}
+
+func sectionName(k shared.Kind) string {
+	switch k {
+	case shared.KindInput:
+		return "SOURCES"
+	case shared.KindFilter:
+		return "FILTERS"
+	case shared.KindOutput:
+		return "OUTPUTS"
+	default:
+		return "ALL"
+	}
+}

+ 47 - 0
main.go

@@ -0,0 +1,47 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"time"
+
+	"fbtop/internal/app"
+	"fbtop/internal/shared"
+
+	tea "charm.land/bubbletea/v2"
+)
+
+func main() {
+	url := flag.String("u", "http://127.0.0.1:2020", "Fluent Bit URL")
+	interval := flag.Duration("i", time.Second, "Poll interval")
+	humanize := flag.Bool("H", true, "Humanize numbers")
+	sortStr := flag.String("s", "id", "Sort column: id|records|bytes")
+	sortDesc := flag.Bool("S", false, "Reverse sort order")
+	kindStr := flag.String("k", "all", "Filter kind: input|filter|output|all")
+	flag.Parse()
+
+	sc := shared.SortID
+	switch *sortStr {
+	case "records":
+		sc = shared.SortRecords
+	case "bytes":
+		sc = shared.SortBytes
+	}
+
+	k := shared.KindAll
+	switch *kindStr {
+	case "input":
+		k = shared.KindInput
+	case "filter":
+		k = shared.KindFilter
+	case "output":
+		k = shared.KindOutput
+	}
+
+	m := app.New(*url, *interval, *humanize, sc, !*sortDesc, k)
+	if _, err := tea.NewProgram(m).Run(); err != nil {
+		fmt.Fprintf(os.Stderr, "error: %v\n", err)
+		os.Exit(1)
+	}
+}