-
-
Save xyproto/5b7317c22532873b8a4efde72a7a018e to your computer and use it in GitHub Desktop.
systemd service starter/stopper with a TUI, for Linux
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bufio" | |
| "fmt" | |
| "os" | |
| "os/exec" | |
| "strings" | |
| tea "github.com/charmbracelet/bubbletea" | |
| "github.com/charmbracelet/lipgloss" | |
| ) | |
| type serviceInfo struct { | |
| Name string | |
| Description string | |
| ActiveState string | |
| SubState string | |
| MainPID string | |
| Memory string | |
| ActiveSince string | |
| LoadState string | |
| } | |
| type model struct { | |
| service string | |
| info serviceInfo | |
| cursor int | |
| choices []string | |
| done bool | |
| err error | |
| } | |
| func queryServiceInfo(svc string) (serviceInfo, bool, error) { | |
| cmd := exec.Command("systemctl", "show", svc, | |
| "-p", "Id", "-p", "Description", | |
| "-p", "ActiveState", "-p", "SubState", | |
| "-p", "MainPID", "-p", "MemoryCurrent", | |
| "-p", "ExecMainStartTimestamp", "-p", "LoadState") | |
| out, err := cmd.Output() | |
| if err != nil { | |
| return serviceInfo{}, false, err | |
| } | |
| info := serviceInfo{Name: svc} | |
| scanner := bufio.NewScanner(strings.NewReader(string(out))) | |
| for scanner.Scan() { | |
| line := scanner.Text() | |
| if !strings.Contains(line, "=") { | |
| continue | |
| } | |
| parts := strings.SplitN(line, "=", 2) | |
| key, val := parts[0], parts[1] | |
| switch key { | |
| case "Id": | |
| info.Name = val | |
| case "Description": | |
| info.Description = val | |
| case "ActiveState": | |
| info.ActiveState = val | |
| case "SubState": | |
| info.SubState = val | |
| case "MainPID": | |
| info.MainPID = val | |
| case "LoadState": | |
| info.LoadState = val | |
| case "MemoryCurrent": | |
| if val != "" && val != "0" { | |
| info.Memory = humanizeBytes(val) | |
| } | |
| case "ExecMainStartTimestamp": | |
| info.ActiveSince = val | |
| } | |
| } | |
| if info.LoadState == "not-found" { | |
| return info, false, fmt.Errorf("service not found: %s", lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(svc)) | |
| } | |
| running := info.ActiveState == "active" | |
| return info, running, nil | |
| } | |
| func humanizeBytes(s string) string { | |
| var bytes int64 | |
| fmt.Sscanf(s, "%d", &bytes) | |
| const unit = 1024 | |
| if bytes < unit { | |
| return fmt.Sprintf("%d B", bytes) | |
| } | |
| div, exp := int64(unit), 0 | |
| for n := bytes / unit; n >= unit; n /= unit { | |
| div *= unit | |
| exp++ | |
| } | |
| return fmt.Sprintf("%.1f %ciB", | |
| float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| } | |
| func runSystemctl(action, svc string) error { | |
| cmd := exec.Command("sudo", "systemctl", action, svc) | |
| cmd.Stdin = os.Stdin | |
| cmd.Stdout = os.Stdout | |
| cmd.Stderr = os.Stderr | |
| return cmd.Run() | |
| } | |
| func (m model) Init() tea.Cmd { return nil } | |
| func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| switch msg := msg.(type) { | |
| case tea.KeyMsg: | |
| switch msg.String() { | |
| case "q", "esc", "ctrl+c": | |
| m.done = true | |
| return m, tea.Quit | |
| case "up", "k": | |
| if m.cursor > 0 { | |
| m.cursor-- | |
| } | |
| case "down", "j": | |
| if m.cursor < len(m.choices)-1 { | |
| m.cursor++ | |
| } | |
| case "enter", " ": | |
| action := m.choices[m.cursor] | |
| if err := runSystemctl(action, m.service); err != nil { | |
| m.err = err | |
| m.done = true | |
| return m, tea.Quit | |
| } | |
| info, _, _ := queryServiceInfo(m.service) | |
| m.info = info | |
| m.done = true | |
| return m, tea.Quit | |
| case "a": | |
| if err := runSystemctl("start", m.service); err != nil { | |
| m.err = err | |
| m.done = true | |
| return m, tea.Quit | |
| } | |
| info, _, _ := queryServiceInfo(m.service) | |
| m.info = info | |
| m.done = true | |
| return m, tea.Quit | |
| case "b": | |
| if err := runSystemctl("stop", m.service); err != nil { | |
| m.err = err | |
| m.done = true | |
| return m, tea.Quit | |
| } | |
| info, _, _ := queryServiceInfo(m.service) | |
| m.info = info | |
| m.done = true | |
| return m, tea.Quit | |
| case "t": | |
| action := "start" | |
| if m.info.ActiveState == "active" { | |
| action = "stop" | |
| } | |
| if err := runSystemctl(action, m.service); err != nil { | |
| m.err = err | |
| m.done = true | |
| return m, tea.Quit | |
| } | |
| info, _, _ := queryServiceInfo(m.service) | |
| m.info = info | |
| m.done = true | |
| return m, tea.Quit | |
| } | |
| } | |
| return m, nil | |
| } | |
| func (m model) View() string { | |
| if !m.done { | |
| return renderInfo(m.info) + "\n\n" + m.menuView() + "\n" + m.helpView() | |
| } | |
| if m.err != nil { | |
| return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.err.Error()) | |
| } | |
| return renderInfo(m.info) | |
| } | |
| func renderInfo(info serviceInfo) string { | |
| green := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) | |
| red := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) | |
| yellow := lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true) | |
| state := yellow.Render(info.ActiveState) | |
| if info.ActiveState == "active" { | |
| state = green.Render("active") | |
| } else if info.ActiveState == "inactive" { | |
| state = red.Render("inactive") | |
| } | |
| s := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%s [%s]", info.Name, state)) | |
| if info.Description != "" { | |
| s += "\n" + info.Description | |
| } | |
| if info.SubState != "" && !(info.ActiveState == "inactive" && info.SubState == "dead") { | |
| s += "\nSubstate: " + info.SubState | |
| } | |
| if info.ActiveState == "active" { | |
| if info.MainPID != "0" && info.MainPID != "" { | |
| s += "\nPID: " + info.MainPID | |
| } | |
| if info.Memory != "" { | |
| s += "\nMemory: " + info.Memory | |
| } | |
| if info.ActiveSince != "" { | |
| s += "\nSince: " + info.ActiveSince | |
| } | |
| } | |
| return s | |
| } | |
| func (m model) menuView() string { | |
| var b strings.Builder | |
| for i, choice := range m.choices { | |
| cursor := " " | |
| if m.cursor == i { | |
| cursor = "->" | |
| } | |
| line := fmt.Sprintf("%s %s", cursor, choice) | |
| if m.cursor == i { | |
| line = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(line) | |
| } | |
| b.WriteString(line + "\n") | |
| } | |
| return b.String() | |
| } | |
| func (m model) helpView() string { | |
| help := "[↑/↓] select, [enter] confirm, [a] start, [b] stop, [t] toggle, [q/esc/ctrl+c] quit" | |
| return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(help) | |
| } | |
| func main() { | |
| if len(os.Args) < 2 { | |
| fmt.Fprintf(os.Stderr, "Usage: %s <service>\n", os.Args[0]) | |
| os.Exit(1) | |
| } | |
| svc := os.Args[1] | |
| info, running, err := queryServiceInfo(svc) | |
| if err != nil { | |
| fmt.Fprintln(os.Stderr, err.Error()) | |
| os.Exit(1) | |
| } | |
| choices := []string{"start", "stop"} | |
| cursor := 0 | |
| if running { | |
| cursor = 1 | |
| } | |
| m := model{service: svc, info: info, choices: choices, cursor: cursor} | |
| if _, err := tea.NewProgram(m).Run(); err != nil { | |
| fmt.Fprintln(os.Stderr, "Error:", err) | |
| os.Exit(1) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment