Skip to content

Instantly share code, notes, and snippets.

@xyproto
Created October 8, 2025 13:19
Show Gist options
  • Save xyproto/5b7317c22532873b8a4efde72a7a018e to your computer and use it in GitHub Desktop.
Save xyproto/5b7317c22532873b8a4efde72a7a018e to your computer and use it in GitHub Desktop.
systemd service starter/stopper with a TUI, for Linux
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