This commit is contained in:
2026-04-22 16:12:50 +02:00
parent 26a098baec
commit 0ee35713a4
11 changed files with 853 additions and 0 deletions
+269
View File
@@ -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)
}
+29
View File
@@ -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
)
+46
View File
@@ -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=
+17
View File
@@ -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},
}
}
+51
View File
@@ -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"),
),
}
+15
View File
@@ -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)
}
}
+151
View File
@@ -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
}
+121
View File
@@ -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()
}
}
+33
View File
@@ -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
}
}
+8
View File
@@ -0,0 +1,8 @@
package main
func If[T any](cond bool, vtrue, vfalse T) T {
if cond {
return vtrue
}
return vfalse
}
+113
View File
@@ -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())
}