381 lines
11 KiB
Go
Raw Normal View History

2026-03-12 20:51:38 -07:00
package tui
import (
"fmt"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── Service Definitions ─────────────────────────────────────────────
type serviceInfo struct {
Name string
Unit string // systemd unit name
Desc string
Binary string // path to check
}
var managedServices = []serviceInfo{
{"AUTARCH Web", "autarch-web", "Web dashboard (Flask)", "autarch_web.py"},
{"AUTARCH DNS", "autarch-dns", "DNS server (Go)", "autarch-dns"},
{"AUTARCH Autonomy", "autarch-autonomy", "Autonomous AI daemon", ""},
}
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderServiceMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("SERVICE MANAGEMENT"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
// Show service statuses — checks both systemd and raw processes
svcChecks := []struct {
Info serviceInfo
Process string // process name to pgrep for
}{
{managedServices[0], "autarch_web.py"},
{managedServices[1], "autarch-dns"},
{managedServices[2], "autonomy"},
}
for _, sc := range svcChecks {
status, running := getProcessStatus(sc.Info.Unit, sc.Process)
indicator := styleStatusOK.Render("● running")
if !running {
indicator = styleStatusBad.Render("○ stopped")
}
b.WriteString(fmt.Sprintf(" %s %s\n",
indicator,
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(sc.Info.Name),
))
b.WriteString(fmt.Sprintf(" %s %s\n",
styleDim.Render(sc.Info.Desc),
styleDim.Render("("+status+")"),
))
b.WriteString("\n")
}
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [1]") + " Start/Stop AUTARCH Web\n")
b.WriteString(styleKey.Render(" [2]") + " Start/Stop AUTARCH DNS\n")
b.WriteString(styleKey.Render(" [3]") + " Start/Stop Autonomy Daemon\n")
b.WriteString("\n")
b.WriteString(styleKey.Render(" [r]") + " Restart all running services\n")
b.WriteString(styleKey.Render(" [e]") + " Enable all services on boot\n")
b.WriteString(styleKey.Render(" [i]") + " Install/update systemd unit files\n")
b.WriteString(styleKey.Render(" [l]") + " View service logs (journalctl)\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleServiceMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "1":
return a.toggleService(0)
case "2":
return a.toggleService(1)
case "3":
return a.toggleService(2)
case "r":
return a.restartAllServices()
case "e":
return a.enableAllServices()
case "i":
return a.installServiceUnits()
case "l":
return a.viewServiceLogs()
}
return a, nil
}
// ── Commands ────────────────────────────────────────────────────────
func (a App) loadServiceStatus() tea.Cmd {
return nil // Services are checked live in render
}
func getServiceStatus(unit string) (string, bool) {
out, err := exec.Command("systemctl", "is-active", unit).Output()
status := strings.TrimSpace(string(out))
if err != nil || status != "active" {
// Check if unit exists
_, existErr := exec.Command("systemctl", "cat", unit).Output()
if existErr != nil {
return "not installed", false
}
return status, false
}
return status, true
}
// getProcessStatus checks both systemd and direct process for a service.
// Returns (status description, isRunning).
func getProcessStatus(unitName, processName string) (string, bool) {
// First try systemd
status, running := getServiceStatus(unitName)
if running {
return "systemd: " + status, true
}
// Fall back to process detection (pgrep)
out, err := exec.Command("pgrep", "-f", processName).Output()
if err == nil && strings.TrimSpace(string(out)) != "" {
pids := strings.Fields(strings.TrimSpace(string(out)))
return fmt.Sprintf("running (PID %s)", pids[0]), true
}
return status, false
}
func (a App) toggleService(idx int) (App, tea.Cmd) {
if idx < 0 || idx >= len(managedServices) {
return a, nil
}
svc := managedServices[idx]
processNames := []string{"autarch_web.py", "autarch-dns", "autonomy"}
procName := processNames[idx]
_, sysRunning := getServiceStatus(svc.Unit)
_, procRunning := getProcessStatus(svc.Unit, procName)
isRunning := sysRunning || procRunning
return a, func() tea.Msg {
dir := findAutarchDir()
if isRunning {
// Stop — try systemd first, then kill process
if sysRunning {
cmd := exec.Command("systemctl", "stop", svc.Unit)
cmd.CombinedOutput()
}
// Also kill any direct processes
exec.Command("pkill", "-f", procName).Run()
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{svc.Name + " stopped."},
}
}
// Start — try systemd first, fall back to direct launch
if _, err := exec.Command("systemctl", "cat", svc.Unit).Output(); err == nil {
cmd := exec.Command("systemctl", "start", svc.Unit)
out, err := cmd.CombinedOutput()
if err != nil {
return ResultMsg{
Title: "Service Error",
Lines: []string{"systemctl start failed:", string(out), err.Error(), "", "Trying direct launch..."},
IsError: true,
}
}
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{svc.Name + " started via systemd."},
}
}
// Direct launch (no systemd unit installed)
var startCmd *exec.Cmd
switch idx {
case 0: // Web
venvPy := dir + "/venv/bin/python3"
startCmd = exec.Command(venvPy, dir+"/autarch_web.py")
case 1: // DNS
binary := dir + "/services/dns-server/autarch-dns"
configFile := dir + "/data/dns/config.json"
startCmd = exec.Command(binary, "--config", configFile)
case 2: // Autonomy
venvPy := dir + "/venv/bin/python3"
startCmd = exec.Command(venvPy, "-c",
"import sys; sys.path.insert(0,'"+dir+"'); from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()")
}
if startCmd != nil {
startCmd.Dir = dir
// Detach process so it survives manager exit
startCmd.Stdout = nil
startCmd.Stderr = nil
if err := startCmd.Start(); err != nil {
return ResultMsg{
Title: "Service Error",
Lines: []string{"Failed to start " + svc.Name + ":", err.Error()},
IsError: true,
}
}
// Release so it runs independently
go startCmd.Wait()
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{
svc.Name + " started directly (PID " + fmt.Sprintf("%d", startCmd.Process.Pid) + ").",
"",
styleDim.Render("Tip: Install systemd units with [i] for persistent service management."),
},
}
}
return ResultMsg{
Title: "Error",
Lines: []string{"No start method available for " + svc.Name},
IsError: true,
}
}
}
func (a App) restartAllServices() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
_, running := getServiceStatus(svc.Unit)
if running {
cmd := exec.Command("systemctl", "restart", svc.Unit)
out, err := cmd.CombinedOutput()
if err != nil {
lines = append(lines, styleError.Render("✘ "+svc.Name+": "+strings.TrimSpace(string(out))))
} else {
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": restarted"))
}
} else {
lines = append(lines, styleDim.Render("- "+svc.Name+": not running, skipped"))
}
}
return ResultMsg{Title: "Restart Services", Lines: lines}
}
}
func (a App) enableAllServices() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
cmd := exec.Command("systemctl", "enable", svc.Unit)
_, err := cmd.CombinedOutput()
if err != nil {
lines = append(lines, styleWarning.Render("⚠ "+svc.Name+": could not enable (unit may not exist)"))
} else {
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": enabled on boot"))
}
}
return ResultMsg{Title: "Enable Services", Lines: lines}
}
}
func (a App) installServiceUnits() (App, tea.Cmd) {
return a, func() tea.Msg {
dir := findAutarchDir()
var lines []string
// Web service unit
webUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/venv/bin/python3 %s/autarch_web.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
`, dir, dir, dir)
// DNS service unit
dnsUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH DNS Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`, dir, dir, dir)
// Autonomy daemon unit
autoUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH Autonomy Daemon
After=network.target autarch-web.service
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/venv/bin/python3 -c "from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()"
Restart=on-failure
RestartSec=10
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
`, dir, dir)
units := map[string]string{
"autarch-web.service": webUnit,
"autarch-dns.service": dnsUnit,
"autarch-autonomy.service": autoUnit,
}
for name, content := range units {
path := "/etc/systemd/system/" + name
if err := writeFileAtomic(path, []byte(content)); err != nil {
lines = append(lines, styleError.Render("✘ "+name+": "+err.Error()))
} else {
lines = append(lines, styleSuccess.Render("✔ "+name+": installed"))
}
}
// Reload systemd
exec.Command("systemctl", "daemon-reload").Run()
lines = append(lines, "", styleSuccess.Render("✔ systemctl daemon-reload"))
return ResultMsg{Title: "Service Units Installed", Lines: lines}
}
}
func (a App) viewServiceLogs() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
out, _ := exec.Command("journalctl", "-u", svc.Unit, "-n", "10", "--no-pager").Output()
lines = append(lines, styleKey.Render("── "+svc.Name+" ──"))
logLines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, l := range logLines {
lines = append(lines, " "+l)
}
lines = append(lines, "")
}
return ResultMsg{Title: "Service Logs (last 10 entries)", Lines: lines}
}
}
func writeFileAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := writeFile(tmp, data, 0644); err != nil {
return err
}
return renameFile(tmp, path)
}