From 0ee35713a4a77ad36d1e56e362a1956ea04ae0cd Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 22 Apr 2026 16:12:50 +0200 Subject: [PATCH] init --- detail.go | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 29 ++++++ go.sum | 46 +++++++++ help.go | 17 ++++ keys.go | 51 ++++++++++ main.go | 15 +++ model.go | 151 ++++++++++++++++++++++++++++++ service.go | 121 ++++++++++++++++++++++++ status.go | 33 +++++++ util.go | 8 ++ view.go | 113 ++++++++++++++++++++++ 11 files changed, 853 insertions(+) create mode 100644 detail.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 help.go create mode 100644 keys.go create mode 100644 main.go create mode 100644 model.go create mode 100644 service.go create mode 100644 status.go create mode 100644 util.go create mode 100644 view.go diff --git a/detail.go b/detail.go new file mode 100644 index 0000000..a384d7c --- /dev/null +++ b/detail.go @@ -0,0 +1,269 @@ +package main + +import ( + "bufio" + "fmt" + "math" + "os" + "os/exec" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type SysValue[T int | float64] struct { + Val T + Fmt string + Inf bool +} + +type Detail struct { + Name string + Desc string + Memory SysValue[float64] + MemoryMax SysValue[float64] + Tasks SysValue[int] + TasksMax SysValue[int] + Cpu float64 + Active string + Load string + Sub string +} + +type DetailLoadedMsg struct { + Detail Detail + Err error +} + +func (m *model) LoadDetail() tea.Msg { + d, err := m.loadDetail() + + if err != nil { + return DetailLoadedMsg{Err: err} + } + + if d == nil { + return DetailLoadedMsg{} + } + + cpu, _ := strconv.ParseFloat(d["CPUUsageNSec"], 64) + + return DetailLoadedMsg{Detail: Detail{ + Name: m.filtered[m.cursor].Name, + Desc: m.filtered[m.cursor].Desc, + Memory: take[float64](d, "MemoryCurrent", "", "B", 2, 1000), + MemoryMax: take[float64](d, "MemoryMax", "", "B", 2, 1000), + Tasks: take[int](d, "TasksCurrent", "", "", 1, 1000), + TasksMax: take[int](d, "TasksMax", "", "", 1, 1000), + Cpu: cpu / 1000000000, + Active: d["ActiveState"], + Load: d["LoadState"], + Sub: d["SubState"], + }} +} + +func take[T int | float64](d map[string]string, key string, start string, unit string, decimals int, step int) SysValue[T] { + val, _ := takeVal[T](d, key, start) + inf := normUnit(d, key, start) == "" + fmt := formatUnit(d, key, start, unit, decimals, step) + return SysValue[T]{Val: val, Inf: inf, Fmt: fmt} +} + +func (v SysValue[T]) Alt(alt SysValue[T]) SysValue[T] { + if v.Inf { + return alt + } + return v +} + +func (v SysValue[T]) Div(y SysValue[T]) float64 { + return float64(v.Val) / float64(y.Val) +} + +func takeVal[T int | float64](d map[string]string, key string, start string) (T, error) { + x := normUnit(d, key, start) + var zero T + switch any(zero).(type) { + case int: + v, err := strconv.Atoi(x) + return any(v).(T), err + case float64: + v, err := strconv.ParseFloat(x, 64) + return any(v).(T), err + default: + return zero, fmt.Errorf("unsupported type") + } +} + +func (m *model) loadDetail() (map[string]string, error) { + if len(m.filtered) == 0 { + return nil, nil + } + + detail := make(map[string]string) + + name := m.filtered[m.cursor].Name + + cmd := exec.Command( + "systemctl", "show", name, + "-p", "CPUUsageNSec", + "-p", "CPUQuotaPerSecUSec", + "-p", "MemoryCurrent", + "-p", "MemoryMax", + "-p", "TasksCurrent", + "-p", "TasksMax", + "-p", "ActiveState", + "-p", "LoadState", + "-p", "SubState", + ) + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + lines := strings.SplitSeq(string(out), "\n") + + for line := range lines { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + detail[parts[0]] = parts[1] + } + } + + return detail, nil +} + +type System struct { + Memory SysValue[float64] + Tasks SysValue[int] +} + +type SystemLoadedMsg struct { + System System + Err error +} + +func LoadSystem() tea.Msg { + d, err := loadMemSystem() + + if err != nil { + return SystemLoadedMsg{Err: err} + } + + if d == nil { + return SystemLoadedMsg{} + } + + return SystemLoadedMsg{System: System{ + Memory: take[float64](d, "MemTotal", "K", "B", 2, 1000), + Tasks: take[int](d, "TasksMax", "", "", 1, 1000), + }} +} + +func loadMemSystem() (map[string]string, error) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return nil, err + } + defer f.Close() + + l := make(map[string]string) + + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + key := strings.TrimSuffix(parts[0], ":") + val, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + continue + } + + l[key] = strconv.FormatUint(val, 10) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return l, nil +} + +func normUnit(dic map[string]string, key string, start string) string { + start = strings.ToUpper(start) + + st := dic[key] + + if st == "infinity" || st == "" { + return "" + } + + sizes := []string{"", "K", "M", "G", "T", "P", "E", "Z", "Y"} + + n := 0 + for i, s := range sizes { + if s == start { + n = i + break + } + } + + return fmt.Sprintf("%s%s", dic[key], strings.Repeat("0", n*3)) +} + +func formatUnit(dic map[string]string, key string, start string, unit string, decimals int, step int) string { + start = strings.ToUpper(start) + fstep := float64(step) + + st := dic[key] + + if st == "infinity" || st == "" { + return fmt.Sprintf("∞ %s", unit) + } + + value, _ := strconv.ParseFloat(st, 64) + + if value == 0 { + return fmt.Sprintf("0 %s%s", start, unit) + } + + sizes := []string{"", "K", "M", "G", "T", "P", "E", "Z", "Y"} + + n := 0 + for i, s := range sizes { + if s == start { + n = i + break + } + } + + i := n + int(math.Floor(math.Log(value)/math.Log(fstep))) + if i < 0 { + i = 0 + } + if i >= len(sizes) { + i = len(sizes) - 1 + } + + value = value / math.Pow(fstep, float64(i-n)) + + if math.Abs(value-math.Round(value)) < 0.05 { + value = math.Round(value) + } + + var formatted string + if math.Mod(value, 1) == 0 { + formatted = fmt.Sprintf("%.0f", value) + } else { + format := fmt.Sprintf("%%.%df", decimals) + formatted = fmt.Sprintf(format, value) + } + + return fmt.Sprintf("%s %s%s", formatted, sizes[i], unit) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1bece49 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module sysui + +go 1.26.2 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/lipgloss/v2 v2.0.3 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // 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.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // 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.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6aaaf49 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +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.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/help.go b/help.go new file mode 100644 index 0000000..6d05e7f --- /dev/null +++ b/help.go @@ -0,0 +1,17 @@ +package main + +import ( + "charm.land/bubbles/v2/key" +) + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down}, + {k.Search, k.Reload}, + {k.Help, k.Quit}, + } +} diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..234e546 --- /dev/null +++ b/keys.go @@ -0,0 +1,51 @@ +package main + +import ( + "charm.land/bubbles/v2/key" +) + +type keyMap struct { + Up key.Binding + Down key.Binding + Search key.Binding + Back key.Binding + Enter key.Binding + Help key.Binding + Reload key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k", "w"), + key.WithHelp("↑/k/w", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j", "s"), + key.WithHelp("↓/j/s", "move down"), + ), + Search: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "search"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "enter"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Reload: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reload"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fdd5ade --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" +) + +func main() { + if _, err := tea.NewProgram(initModel()).Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..8f856a8 --- /dev/null +++ b/model.go @@ -0,0 +1,151 @@ +package main + +import ( + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +type model struct { + err error + width int + height int + amount int + + keys keyMap + + services []Service + filtered []Service + query textinput.Model + cursor int + offset int + + help help.Model + + system System + detail Detail +} + +func initModel() model { + query := textinput.New() + query.Placeholder = "Search..." + + return model{ + keys: keys, + query: query, + help: help.New(), + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(LoadSystem, m.TickDetails()) +} + +type DetailTickMsg struct{} + +func (m *model) TickDetails() tea.Cmd { + return tea.Tick(1*time.Second, func(time.Time) tea.Msg { + return DetailTickMsg{} + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.query.SetWidth(msg.Width) + m.help.SetWidth(msg.Width) + m.width = msg.Width + m.height = msg.Height + m.calcAmount() + return m, nil + + case ServicesLoadedMsg: + m.services = msg.services + m.err = msg.err + m.filterServices() + return m, m.LoadDetail + + case SystemLoadedMsg: + m.system = msg.System + m.err = msg.Err + return m, LoadServices + + case DetailLoadedMsg: + m.detail = msg.Detail + m.err = msg.Err + for si, s := range m.services { + if s.Name == msg.Detail.Name && s.Active != msg.Detail.Active { + m.services[si].Active = msg.Detail.Active + for fi, f := range m.filtered { + if f.Name == msg.Detail.Name { + m.filtered[fi].Active = msg.Detail.Active + } + } + } + } + return m, nil + + case DetailTickMsg: + return m, tea.Batch(m.LoadDetail, m.TickDetails()) + + case tea.KeyPressMsg: + if m.query.Focused() { + switch { + case key.Matches(msg, m.keys.Back), key.Matches(msg, m.keys.Enter): + m.query.Blur() + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + } + m.query, cmd = m.query.Update(msg) + m.filterServices() + return m, tea.Batch(cmd, m.LoadDetail) + } + + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.offset { + m.offset-- + } + } + return m, m.LoadDetail + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.filtered)-1 { + m.cursor++ + if m.cursor >= m.offset+m.amount { + m.offset++ + } + } + return m, m.LoadDetail + case key.Matches(msg, m.keys.Search): + m.query.Focus() + case key.Matches(msg, m.keys.Back): + m.query.Blur() + case key.Matches(msg, m.keys.Enter): + s := m.filtered[m.cursor] + if s.Active != "active" { + m.StartService() + } else { + m.StopService() + } + case key.Matches(msg, m.keys.Reload): + return m, LoadServices + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + m.calcAmount() + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + } + } + + return m, cmd +} + +func (m *model) calcAmount() { + m.amount = (m.height - 2 - If(m.help.ShowAll, 2, 1)) / 3 +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..520bdf2 --- /dev/null +++ b/service.go @@ -0,0 +1,121 @@ +package main + +import ( + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type Service struct { + Name string + Desc string + Active string +} + +type ServicesLoadedMsg struct { + services []Service + err error +} + +func LoadServices() tea.Msg { + cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--plain") + out, err := cmd.Output() + if err != nil { + return ServicesLoadedMsg{err: err} + } + + services := parseServices(out) + return ServicesLoadedMsg{services: services} +} + +func parseServices(out []byte) []Service { + lines := strings.Split(string(out), "\n") + var services []Service + + for i, line := range lines { + if i == 0 { + continue + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if strings.HasPrefix(line, "Legend:") { + return services + } + + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + services = append(services, Service{ + Name: fields[0], + Active: fields[2], + Desc: strings.Join(fields[4:], " "), + }) + } + + return services +} + +func (m *model) filterServices() { + q := strings.ToLower(strings.TrimSpace(m.query.Value())) + + if q == "" { + m.filtered = slicesClone(m.services) + } else { + var filtered []Service + for _, s := range m.services { + if matchesService(s, q) { + filtered = append(filtered, s) + } + } + m.filtered = filtered + } + + if len(m.filtered) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + m.offset = max(0, m.cursor-m.amount) + } +} + +func matchesService(s Service, q string) bool { + return strings.Contains(strings.ToLower(s.Name), q) || + strings.Contains(strings.ToLower(s.Active), q) || + strings.Contains(strings.ToLower(s.Desc), q) +} + +func slicesClone(in []Service) []Service { + out := make([]Service, len(in)) + copy(out, in) + return out +} + +func (m *model) StopService() { + s := m.filtered[m.cursor] + + if s.Active != "active" { + return + } + + cmd := exec.Command("systemctl", "stop", s.Name) + cmd.Run() +} + +func (m *model) StartService() { + s := m.filtered[m.cursor] + + if s.Active != "active" { + cmd := exec.Command("systemctl", "start", s.Name) + cmd.Run() + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..f84667e --- /dev/null +++ b/status.go @@ -0,0 +1,33 @@ +package main + +import ( + "charm.land/lipgloss/v2" +) + +var ( + greenDot = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("●") + redDot = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") + yellowDot = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("●") + grayDot = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("●") + whiteDot = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Render("●") +) + +var ( + stop = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("[STOP]") + start = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("[START]") +) + +func status(s string) string { + switch s { + case "active": + return greenDot + case "failed": + return redDot + case "activating", "reloading": + return yellowDot + case "inactive": + return grayDot + default: + return whiteDot + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..2df0a97 --- /dev/null +++ b/util.go @@ -0,0 +1,8 @@ +package main + +func If[T any](cond bool, vtrue, vfalse T) T { + if cond { + return vtrue + } + return vfalse +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..a226d96 --- /dev/null +++ b/view.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/progress" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +func (m model) View() tea.View { + view := tea.NewView(view(m)) + + view.AltScreen = true + + return view +} + +func view(m model) string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + if m.err != nil { + return fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err) + } + + var b strings.Builder + + renderQuery(&b, m) + + if len(m.filtered) == 0 { + b.WriteString(" No services found.\n") + renderHelp(&b, m) + return b.String() + } + + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, renderList(m), renderDetails(m))) + + renderHelp(&b, m) + + return b.String() +} + +func renderQuery(b *strings.Builder, m model) { + b.WriteString(m.query.View()) + b.WriteString("\n\n") +} + +func renderHelp(b *strings.Builder, m model) { + if height := m.height - strings.Count(b.String(), "\n") - If(m.help.ShowAll, 2, 1); height > 0 { + b.WriteString(strings.Repeat("\n", height)) + } + + b.WriteString(m.help.View(m.keys)) +} + +func renderList(m model) string { + var panel = lipgloss.NewStyle().Height(m.amount*3 - 1).Width(m.width / 2) + + var b strings.Builder + + last := min(len(m.filtered), m.offset+m.amount) + + for i := m.offset; i < last; i++ { + s := m.filtered[i] + + cursor := " " + if i == m.cursor { + cursor = ">" + } + + status := status(s.Active) + + line := fmt.Sprintf( + "%s %s %s \n %s\n"+If(i == last-1, "", "\n"), + cursor, status, s.Name, s.Desc, + ) + b.WriteString(line) + } + + return panel.Render(b.String()) +} + +func renderDetails(m model) string { + var panel = lipgloss.NewStyle().Height(m.amount*3 - 1).MaxWidth(m.width / 2) + + var b strings.Builder + + d := m.detail + sys := m.system + + fmt.Fprintf(&b, "%s\n%s\n\n", d.Name, d.Desc) + + prog := progress.New(progress.WithColors(lipgloss.White), progress.WithFillCharacters('█', '░'), progress.WithoutPercentage()) + + if d.Active == "active" { + fmt.Fprintf(&b, "▣ Memory - %s / %s\n%s\n\n", d.Memory.Fmt, d.MemoryMax.Alt(sys.Memory).Fmt, prog.ViewAs(d.Memory.Div(d.MemoryMax.Alt(sys.Memory)))) + + fmt.Fprintf(&b, "≡ Tasks - %s / %s\n%s\n\n", d.Tasks.Fmt, d.TasksMax.Alt(sys.Tasks).Fmt, prog.ViewAs(d.Tasks.Div(d.TasksMax.Alt(sys.Tasks)))) + + fmt.Fprintf(&b, "⌁ CPU - %ds\n\n", int(d.Cpu)) + } + + fmt.Fprintf(&b, "%s %s / %s / %s\n", status(d.Active), d.Active, d.Load, d.Sub) + + state := If(d.Active == "active" || d.Active == "failed", 1, If(d.Active == "inactive", -1, 0)) + + b.WriteString(lipgloss.NewStyle().Width(prog.Width()).Align(lipgloss.Right).Render(If(state == 1, stop, If(state == -1, start, "")))) + + return panel.Render(b.String()) +}