381 lines
11 KiB
Go
381 lines
11 KiB
Go
|
|
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)
|
||
|
|
}
|