init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package main
|
||||
|
||||
func If[T any](cond bool, vtrue, vfalse T) T {
|
||||
if cond {
|
||||
return vtrue
|
||||
}
|
||||
return vfalse
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user