827 lines
22 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── View IDs ────────────────────────────────────────────────────────
type ViewID int
const (
ViewMain ViewID = iota
ViewDeps
ViewDepsInstall
ViewModules
ViewModuleToggle
ViewSettings
ViewSettingsSection
ViewSettingsEdit
ViewUsers
ViewUsersCreate
ViewUsersReset
ViewService
ViewDNS
ViewDNSBuild
ViewDNSManage
ViewDNSZones
ViewDNSZoneEdit
ViewDeploy
ViewConfirm
ViewResult
)
// ── Styles ──────────────────────────────────────────────────────────
var (
colorRed = lipgloss.Color("#ef4444")
colorGreen = lipgloss.Color("#22c55e")
colorYellow = lipgloss.Color("#eab308")
colorBlue = lipgloss.Color("#6366f1")
colorCyan = lipgloss.Color("#06b6d4")
colorMagenta = lipgloss.Color("#a855f7")
colorDim = lipgloss.Color("#6b7280")
colorWhite = lipgloss.Color("#f9fafb")
colorSurface = lipgloss.Color("#1e1e2e")
colorBorder = lipgloss.Color("#3b3b5c")
styleBanner = lipgloss.NewStyle().
Foreground(colorRed).
Bold(true)
styleTitle = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true).
PaddingLeft(2)
styleSubtitle = lipgloss.NewStyle().
Foreground(colorDim).
PaddingLeft(2)
styleMenuItem = lipgloss.NewStyle().
PaddingLeft(4)
styleSelected = lipgloss.NewStyle().
Foreground(colorBlue).
Bold(true).
PaddingLeft(2)
styleNormal = lipgloss.NewStyle().
Foreground(colorWhite).
PaddingLeft(4)
styleKey = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true)
styleSuccess = lipgloss.NewStyle().
Foreground(colorGreen)
styleError = lipgloss.NewStyle().
Foreground(colorRed)
styleWarning = lipgloss.NewStyle().
Foreground(colorYellow)
styleDim = lipgloss.NewStyle().
Foreground(colorDim)
styleStatusOK = lipgloss.NewStyle().
Foreground(colorGreen).
Bold(true)
styleStatusBad = lipgloss.NewStyle().
Foreground(colorRed).
Bold(true)
styleBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(1, 2)
styleHR = lipgloss.NewStyle().
Foreground(colorDim)
)
// ── Menu Item ───────────────────────────────────────────────────────
type MenuItem struct {
Key string
Label string
Desc string
View ViewID
}
// ── Messages ────────────────────────────────────────────────────────
type ResultMsg struct {
Title string
Lines []string
IsError bool
}
type ConfirmMsg struct {
Prompt string
OnConfirm func() tea.Cmd
}
type OutputLineMsg string
type DoneMsg struct{ Err error }
// ── App Model ───────────────────────────────────────────────────────
type App struct {
width, height int
// Navigation
view ViewID
viewStack []ViewID
cursor int
// Main menu
mainMenu []MenuItem
// Dynamic content
listItems []ListItem
listTitle string
sectionKeys []string
// Settings
settingsSections []string
settingsSection string
settingsKeys []string
settingsVals []string
// Text input
textInput textinput.Model
inputLabel string
inputField string
inputs []textinput.Model
labels []string
focusIdx int
// Result / output
resultTitle string
resultLines []string
resultIsErr bool
outputLines []string
outputDone bool
outputCh chan tea.Msg
progressStep int
progressTotal int
progressLabel string
// Confirm
confirmPrompt string
confirmAction func() tea.Cmd
// Config path
autarchDir string
}
type ListItem struct {
Name string
Status string
Enabled bool
Extra string
}
func NewApp() App {
ti := textinput.New()
ti.CharLimit = 256
app := App{
view: ViewMain,
autarchDir: findAutarchDir(),
textInput: ti,
mainMenu: []MenuItem{
{Key: "1", Label: "Deploy AUTARCH", Desc: "Clone from GitHub, setup dirs, venv, deps, permissions, systemd", View: ViewDeploy},
{Key: "2", Label: "Dependencies", Desc: "Install & manage system packages, Python venv, pip, npm", View: ViewDeps},
{Key: "3", Label: "Modules", Desc: "List, enable, or disable AUTARCH Python modules", View: ViewModules},
{Key: "4", Label: "Settings", Desc: "Edit autarch_settings.conf (all 14+ sections)", View: ViewSettings},
{Key: "5", Label: "Users", Desc: "Create users, reset passwords, manage web credentials", View: ViewUsers},
{Key: "6", Label: "Services", Desc: "Start, stop, restart AUTARCH web & background daemons", View: ViewService},
{Key: "7", Label: "DNS Server", Desc: "Build, configure, and manage the AUTARCH DNS server", View: ViewDNS},
{Key: "q", Label: "Quit", Desc: "Exit the server manager", View: ViewMain},
},
}
return app
}
func (a App) Init() tea.Cmd {
return nil
}
// waitForOutput returns a Cmd that reads the next message from the output channel.
// This creates the streaming chain: OutputLineMsg → waitForOutput → OutputLineMsg → ...
func (a App) waitForOutput() tea.Cmd {
ch := a.outputCh
if ch == nil {
return nil
}
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return DoneMsg{}
}
return msg
}
}
// ── Update ──────────────────────────────────────────────────────────
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
return a, nil
case ResultMsg:
a.pushView(ViewResult)
a.resultTitle = msg.Title
a.resultLines = msg.Lines
a.resultIsErr = msg.IsError
return a, nil
case OutputLineMsg:
a.outputLines = append(a.outputLines, string(msg))
return a, a.waitForOutput()
case ProgressMsg:
a.progressStep = msg.Step
a.progressTotal = msg.Total
a.progressLabel = msg.Label
return a, a.waitForOutput()
case DoneMsg:
a.outputDone = true
a.outputCh = nil
if msg.Err != nil {
a.outputLines = append(a.outputLines, "", styleError.Render("Error: "+msg.Err.Error()))
}
a.outputLines = append(a.outputLines, "", styleDim.Render("Press any key to continue..."))
return a, nil
case depsLoadedMsg:
a.listItems = msg.items
return a, nil
case modulesLoadedMsg:
a.listItems = msg.items
return a, nil
case settingsLoadedMsg:
a.settingsSections = msg.sections
return a, nil
case dnsZonesMsg:
a.listItems = msg.items
return a, nil
case tea.KeyMsg:
return a.handleKey(msg)
}
// Update text inputs if active
if a.isInputView() {
return a.updateInputs(msg)
}
return a, nil
}
func (a App) isInputView() bool {
return a.view == ViewUsersCreate || a.view == ViewUsersReset ||
a.view == ViewSettingsEdit || a.view == ViewDNSZoneEdit
}
func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// Global keys
switch key {
case "ctrl+c":
return a, tea.Quit
}
// Input views get special handling
if a.isInputView() {
return a.handleInputKey(msg)
}
// Output view (streaming)
if a.view == ViewDepsInstall || a.view == ViewDNSBuild {
if a.outputDone {
a.popView()
a.progressStep = 0
a.progressTotal = 0
a.progressLabel = ""
// Reload the parent view's data
switch a.view {
case ViewDeps:
return a, a.loadDepsStatus()
case ViewDNS:
return a, nil
case ViewDeploy:
return a, nil
}
}
return a, nil
}
// Result view
if a.view == ViewResult {
a.popView()
return a, nil
}
// Confirm view
if a.view == ViewConfirm {
switch key {
case "y", "Y":
if a.confirmAction != nil {
cmd := a.confirmAction()
a.popView()
return a, cmd
}
a.popView()
case "n", "N", "esc":
a.popView()
}
return a, nil
}
// List navigation
switch key {
case "up", "k":
if a.cursor > 0 {
a.cursor--
}
return a, nil
case "down", "j":
max := a.maxCursor()
if a.cursor < max {
a.cursor++
}
return a, nil
case "esc":
if len(a.viewStack) > 0 {
a.popView()
}
return a, nil
case "q":
if a.view == ViewMain {
return a, tea.Quit
}
if len(a.viewStack) > 0 {
a.popView()
return a, nil
}
return a, tea.Quit
}
// View-specific handling
switch a.view {
case ViewMain:
return a.handleMainMenu(key)
case ViewDeps:
return a.handleDepsMenu(key)
case ViewModules:
return a.handleModulesMenu(key)
case ViewModuleToggle:
return a.handleModuleToggle(key)
case ViewSettings:
return a.handleSettingsMenu(key)
case ViewSettingsSection:
return a.handleSettingsSection(key)
case ViewUsers:
return a.handleUsersMenu(key)
case ViewService:
return a.handleServiceMenu(key)
case ViewDeploy:
return a.handleDeployMenu(key)
case ViewDNS:
return a.handleDNSMenu(key)
case ViewDNSManage:
return a.handleDNSManageMenu(key)
case ViewDNSZones:
return a.handleDNSZonesMenu(key)
}
return a, nil
}
func (a App) maxCursor() int {
switch a.view {
case ViewMain:
return len(a.mainMenu) - 1
case ViewModules, ViewModuleToggle:
return len(a.listItems) - 1
case ViewSettings:
return len(a.settingsSections) - 1
case ViewSettingsSection:
return len(a.settingsKeys) - 1
case ViewDNSZones:
return len(a.listItems) - 1
}
return 0
}
// ── Navigation ──────────────────────────────────────────────────────
func (a *App) pushView(v ViewID) {
a.viewStack = append(a.viewStack, a.view)
a.view = v
a.cursor = 0
}
func (a *App) popView() {
if len(a.viewStack) > 0 {
a.view = a.viewStack[len(a.viewStack)-1]
a.viewStack = a.viewStack[:len(a.viewStack)-1]
a.cursor = 0
}
}
// ── View Rendering ──────────────────────────────────────────────────
func (a App) View() string {
var b strings.Builder
b.WriteString(a.renderBanner())
b.WriteString("\n")
switch a.view {
case ViewMain:
b.WriteString(a.renderMainMenu())
case ViewDeploy:
b.WriteString(a.renderDeployMenu())
case ViewDeps:
b.WriteString(a.renderDepsMenu())
case ViewDepsInstall:
b.WriteString(a.renderOutput("Installing Dependencies"))
case ViewModules:
b.WriteString(a.renderModulesList())
case ViewModuleToggle:
b.WriteString(a.renderModulesList())
case ViewSettings:
b.WriteString(a.renderSettingsSections())
case ViewSettingsSection:
b.WriteString(a.renderSettingsKeys())
case ViewSettingsEdit:
b.WriteString(a.renderSettingsEditForm())
case ViewUsers:
b.WriteString(a.renderUsersMenu())
case ViewUsersCreate:
b.WriteString(a.renderUserForm("Create New User"))
case ViewUsersReset:
b.WriteString(a.renderUserForm("Reset Password"))
case ViewService:
b.WriteString(a.renderServiceMenu())
case ViewDNS:
b.WriteString(a.renderDNSMenu())
case ViewDNSBuild:
b.WriteString(a.renderOutput("Building DNS Server"))
case ViewDNSManage:
b.WriteString(a.renderDNSManageMenu())
case ViewDNSZones:
b.WriteString(a.renderDNSZones())
case ViewDNSZoneEdit:
b.WriteString(a.renderDNSZoneForm())
case ViewConfirm:
b.WriteString(a.renderConfirm())
case ViewResult:
b.WriteString(a.renderResult())
}
b.WriteString("\n")
b.WriteString(a.renderStatusBar())
return b.String()
}
// ── Banner ──────────────────────────────────────────────────────────
func (a App) renderBanner() string {
banner := `
`
title := lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true).
Align(lipgloss.Center).
Render("S E R V E R M A N A G E R v1.0")
sub := styleDim.Render(" darkHal Security Group & Setec Security Labs")
// Live service status bar
statusLine := a.renderServiceStatusBar()
return styleBanner.Render(banner) + "\n" + title + "\n" + sub + "\n" + statusLine + "\n"
}
func (a App) renderServiceStatusBar() string {
webStatus, webUp := getProcessStatus("autarch-web", "autarch_web.py")
dnsStatus, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
webInd := styleStatusBad.Render("○")
if webUp {
webInd = styleStatusOK.Render("●")
}
dnsInd := styleStatusBad.Render("○")
if dnsUp {
dnsInd = styleStatusOK.Render("●")
}
_ = webStatus
_ = dnsStatus
return styleDim.Render(" ") +
webInd + styleDim.Render(" Web ") +
dnsInd + styleDim.Render(" DNS")
}
func (a App) renderHR() string {
w := a.width
if w < 10 {
w = 66
}
if w > 80 {
w = 80
}
return styleHR.Render(strings.Repeat("─", w-4)) + "\n"
}
// ── Main Menu ───────────────────────────────────────────────────────
func (a App) renderMainMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("MAIN MENU"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, item := range a.mainMenu {
cursor := " "
if i == a.cursor {
cursor = styleSelected.Render("▸ ")
label := styleKey.Render("["+item.Key+"]") + " " +
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Label)
desc := styleDim.Render(" " + item.Desc)
b.WriteString(cursor + label + "\n")
b.WriteString(" " + desc + "\n")
} else {
label := styleDim.Render("["+item.Key+"]") + " " +
lipgloss.NewStyle().Foreground(colorWhite).Render(item.Label)
b.WriteString(cursor + " " + label + "\n")
}
}
return b.String()
}
// ── Confirm ─────────────────────────────────────────────────────────
func (a App) renderConfirm() string {
var b strings.Builder
b.WriteString(styleTitle.Render("CONFIRM"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleWarning.Render(" " + a.confirmPrompt))
b.WriteString("\n\n")
b.WriteString(styleDim.Render(" [Y] Yes [N] No"))
b.WriteString("\n")
return b.String()
}
// ── Result ──────────────────────────────────────────────────────────
func (a App) renderResult() string {
var b strings.Builder
title := a.resultTitle
if a.resultIsErr {
b.WriteString(styleError.Render(" " + title))
} else {
b.WriteString(styleSuccess.Render(" " + title))
}
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for _, line := range a.resultLines {
b.WriteString(" " + line + "\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" Press any key to continue..."))
b.WriteString("\n")
return b.String()
}
// ── Streaming Output ────────────────────────────────────────────────
func (a App) renderOutput(title string) string {
var b strings.Builder
b.WriteString(styleTitle.Render(title))
b.WriteString("\n")
b.WriteString(a.renderHR())
// Progress bar
if a.progressTotal > 0 && !a.outputDone {
pct := float64(a.progressStep) / float64(a.progressTotal)
barWidth := 40
filled := int(pct * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
b.WriteString(" " + styleKey.Render("["+bar+"]") + " " +
styleWarning.Render(pctStr) + " " +
styleDim.Render(fmt.Sprintf("Step %d/%d: %s", a.progressStep, a.progressTotal, a.progressLabel)))
b.WriteString("\n")
b.WriteString(a.renderHR())
}
b.WriteString("\n")
// Show last N lines that fit the screen
maxLines := a.height - 22
if maxLines < 10 {
maxLines = 20
}
start := 0
if len(a.outputLines) > maxLines {
start = len(a.outputLines) - maxLines
}
for _, line := range a.outputLines[start:] {
b.WriteString(" " + line + "\n")
}
if !a.outputDone {
b.WriteString("\n")
b.WriteString(styleDim.Render(" Working..."))
}
return b.String()
}
// ── Status Bar ──────────────────────────────────────────────────────
func (a App) renderStatusBar() string {
nav := styleDim.Render(" ↑↓ navigate")
esc := styleDim.Render(" esc back")
quit := styleDim.Render(" q quit")
path := ""
for _, v := range a.viewStack {
path += viewName(v) + " > "
}
path += viewName(a.view)
left := styleDim.Render(" " + path)
right := nav + esc + quit
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return "\n" + styleHR.Render(strings.Repeat("─", clamp(a.width-4, 20, 80))) + "\n" +
left + strings.Repeat(" ", gap) + right + "\n"
}
func viewName(v ViewID) string {
names := map[ViewID]string{
ViewMain: "Main",
ViewDeps: "Dependencies",
ViewDepsInstall: "Install",
ViewModules: "Modules",
ViewModuleToggle: "Toggle",
ViewSettings: "Settings",
ViewSettingsSection: "Section",
ViewSettingsEdit: "Edit",
ViewUsers: "Users",
ViewUsersCreate: "Create",
ViewUsersReset: "Reset",
ViewService: "Services",
ViewDNS: "DNS",
ViewDNSBuild: "Build",
ViewDNSManage: "Manage",
ViewDNSZones: "Zones",
ViewDeploy: "Deploy",
ViewDNSZoneEdit: "Edit Zone",
ViewConfirm: "Confirm",
ViewResult: "Result",
}
if n, ok := names[v]; ok {
return n
}
return "?"
}
// ── User Form Rendering ─────────────────────────────────────────────
func (a App) renderUserForm(title string) string {
var b strings.Builder
b.WriteString(styleTitle.Render(title))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleDim.Render(label+": "))
b.WriteString(a.inputs[i].View())
b.WriteString("\n\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── Settings Edit Form ──────────────────────────────────────────────
func (a App) renderSettingsEditForm() string {
var b strings.Builder
b.WriteString(styleTitle.Render(fmt.Sprintf("Edit [%s]", a.settingsSection)))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleKey.Render(label) + " = ")
b.WriteString(a.inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next | enter save all | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── DNS Zone Form ───────────────────────────────────────────────────
func (a App) renderDNSZoneForm() string {
var b strings.Builder
b.WriteString(styleTitle.Render("Create DNS Zone"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleDim.Render(label+": "))
b.WriteString(a.inputs[i].View())
b.WriteString("\n\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── Helpers ─────────────────────────────────────────────────────────
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}