Initial implementation of ansibleTUI v1

Keyboard-first Go TUI for homelab Ansible management built with
Bubble Tea, Bubbles, and Lip Gloss.

Screens: home (server table + recent runs), add/edit server form,
4-step check/apply wizard with live output streaming, and run
details log viewer.

Packages:
- internal/config    — config.yaml persistence
- internal/inventory — YAML Ansible inventory CRUD
- internal/playbooks — playbook directory discovery
- internal/ansible   — argv construction + PLAY RECAP parsing
- internal/history   — JSON run records and log files
- internal/runner    — streaming exec via io.Pipe
- internal/ui        — full Bubble Tea UI with mouse support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 00:42:05 -04:00
commit d0b7ecd0e8
21 changed files with 2724 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Compiled binaries
*.exe
*.exe~
ansibletui
ansibletui-linux
# Test binary, built with `go test -c`
*.test
# Output of go coverage tool
*.out
# Go workspace file
go.work
go.work.sum
# IDE
.idea/
*.iml
.vscode/
# OS
.DS_Store
Thumbs.db
+34
View File
@@ -0,0 +1,34 @@
module ansibletui
go 1.24.2
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+56
View File
@@ -0,0 +1,56 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+131
View File
@@ -0,0 +1,131 @@
package ansible
import (
"bufio"
"regexp"
"strconv"
"strings"
)
// Mode represents the ansible-playbook execution mode.
type Mode int
const (
ModeCheck Mode = iota // --check
ModeCheckDiff // --check --diff
ModeApply // no extra flags
)
func (m Mode) String() string {
switch m {
case ModeCheck:
return "check"
case ModeCheckDiff:
return "check+diff"
default:
return "apply"
}
}
// ModeLabel returns a display-friendly label.
func (m Mode) ModeLabel() string {
switch m {
case ModeCheck:
return "Check only"
case ModeCheckDiff:
return "Check + Diff"
default:
return "Apply (live run)"
}
}
// BuildPlaybookArgs constructs the argv for ansible-playbook.
func BuildPlaybookArgs(inventoryPath, playbookPath, limit string, mode Mode) []string {
args := []string{"ansible-playbook", "-i", inventoryPath, playbookPath}
if limit != "" {
args = append(args, "--limit", limit)
}
switch mode {
case ModeCheck:
args = append(args, "--check")
case ModeCheckDiff:
args = append(args, "--check", "--diff")
}
return args
}
// BuildPingArgs constructs argv for ansible ping.
func BuildPingArgs(inventoryPath, host string) []string {
return []string{"ansible", host, "-m", "ping", "-i", inventoryPath}
}
// Recap holds the parsed PLAY RECAP values for one host.
type Recap struct {
Host string
OK int
Changed int
Failed int
Unreachable int
Skipped int
}
// DriftState returns "clean", "drift", "failed", or "unreachable".
func (r *Recap) DriftState() string {
if r.Unreachable > 0 {
return "unreachable"
}
if r.Failed > 0 {
return "failed"
}
if r.Changed > 0 {
return "drift"
}
return "clean"
}
// recapRe matches a single PLAY RECAP host line.
var recapRe = regexp.MustCompile(
`^(\S+)\s+:\s+ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)(?:\s+skipped=(\d+))?`,
)
// ParseRecap scans output for a PLAY RECAP block and returns all host recaps.
func ParseRecap(output string) []Recap {
var recaps []Recap
inRecap := false
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "PLAY RECAP") {
inRecap = true
continue
}
if !inRecap {
continue
}
if strings.TrimSpace(line) == "" {
inRecap = false
continue
}
m := recapRe.FindStringSubmatch(line)
if m == nil {
continue
}
r := Recap{
Host: m[1],
OK: atoi(m[2]),
Changed: atoi(m[3]),
Unreachable: atoi(m[4]),
Failed: atoi(m[5]),
}
if len(m) > 6 && m[6] != "" {
r.Skipped = atoi(m[6])
}
recaps = append(recaps, r)
}
return recaps
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
+77
View File
@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
PlaybookDir string `yaml:"playbook_dir"`
InventoryPath string `yaml:"inventory_path"`
Runtime string `yaml:"runtime"`
RecentPlaybook string `yaml:"recent_playbook"`
}
func AppDir() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ansibletui"), nil
}
func Load() (*Config, error) {
dir, err := AppDir()
if err != nil {
return defaults(""), nil
}
path := filepath.Join(dir, "config.yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return defaults(dir), nil
}
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.InventoryPath == "" {
cfg.InventoryPath = filepath.Join(dir, "inventory.yml")
}
if cfg.Runtime == "" {
cfg.Runtime = "auto"
}
return &cfg, nil
}
func (c *Config) Save() error {
dir, err := AppDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "config.yaml"), data, 0o644)
}
func defaults(dir string) *Config {
home, _ := os.UserHomeDir()
if dir == "" {
cfgDir, _ := os.UserConfigDir()
dir = filepath.Join(cfgDir, "ansibletui")
}
return &Config{
PlaybookDir: filepath.Join(home, "homelab", "ansible"),
InventoryPath: filepath.Join(dir, "inventory.yml"),
Runtime: "auto",
}
}
+147
View File
@@ -0,0 +1,147 @@
package history
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// RunRecord stores metadata about a single check/apply execution.
type RunRecord struct {
ID string `json:"id"`
Playbook string `json:"playbook"`
Host string `json:"host"`
Mode string `json:"mode"`
Status string `json:"status"` // "clean","drift","failed","unreachable","ok"
OK int `json:"ok"`
Changed int `json:"changed"`
Failed int `json:"failed"`
Unreachable int `json:"unreachable"`
Skipped int `json:"skipped"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
LogFile string `json:"log_file"`
ExitCode int `json:"exit_code"`
}
// TimeLabel returns HH:MM for display.
func (r *RunRecord) TimeLabel() string {
return r.StartTime.Format("15:04")
}
// StatusSummary returns a short run status label for display.
func (r *RunRecord) StatusSummary() string {
switch r.Status {
case "drift":
if r.Changed > 0 {
return fmt.Sprintf("changed=%d", r.Changed)
}
return "changed"
case "clean":
return "clean"
case "ok":
return "ok"
case "failed":
if r.Failed > 0 {
return fmt.Sprintf("failed=%d", r.Failed)
}
return "failed"
case "unreachable":
return "unreachable"
default:
return r.Status
}
}
// History manages run records persisted under a directory.
type History struct {
dir string
}
func New(dir string) *History {
return &History{dir: dir}
}
// Save writes the run record JSON and its log file to disk.
func (h *History) Save(rec *RunRecord, log []byte) error {
if err := os.MkdirAll(h.dir, 0o755); err != nil {
return err
}
if rec.ID == "" {
rec.ID = time.Now().UTC().Format("20060102T150405")
}
logName := fmt.Sprintf("%s-%s-%s.log", rec.ID, sanitize(rec.Host), rec.Mode)
rec.LogFile = logName
if err := os.WriteFile(filepath.Join(h.dir, logName), log, 0o644); err != nil {
return err
}
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return err
}
metaName := fmt.Sprintf("%s-%s-%s.json", rec.ID, sanitize(rec.Host), rec.Mode)
return os.WriteFile(filepath.Join(h.dir, metaName), data, 0o644)
}
// LoadLog reads the log file for a record.
func (h *History) LoadLog(rec *RunRecord) ([]byte, error) {
if rec.LogFile == "" {
return nil, fmt.Errorf("no log file recorded")
}
return os.ReadFile(filepath.Join(h.dir, rec.LogFile))
}
// List returns up to n run records, newest first.
func (h *History) List(n int) ([]*RunRecord, error) {
entries, err := os.ReadDir(h.dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var records []*RunRecord
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(h.dir, e.Name()))
if err != nil {
continue
}
var rec RunRecord
if err := json.Unmarshal(data, &rec); err != nil {
continue
}
records = append(records, &rec)
}
sort.Slice(records, func(i, j int) bool {
return records[i].StartTime.After(records[j].StartTime)
})
if n > 0 && len(records) > n {
records = records[:n]
}
return records, nil
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
+181
View File
@@ -0,0 +1,181 @@
package inventory
import (
"fmt"
"os"
"path/filepath"
"sort"
"gopkg.in/yaml.v3"
)
// Host represents a managed server entry.
type Host struct {
Name string
Group string
AnsibleHost string
AnsibleUser string
AnsiblePort int
// Runtime state — not written to the inventory file.
Reachable *bool // nil = unknown
DriftState string // "clean", "drift", "unknown", "failed"
LastCheck string
LastApply string
LastError string
}
// Ansible YAML inventory wire types.
type rawInventory struct {
All rawAll `yaml:"all"`
}
type rawAll struct {
Hosts map[string]*rawHostVars `yaml:"hosts,omitempty"`
Children map[string]*rawGroup `yaml:"children,omitempty"`
}
type rawHostVars struct {
AnsibleHost string `yaml:"ansible_host,omitempty"`
AnsibleUser string `yaml:"ansible_user,omitempty"`
AnsiblePort int `yaml:"ansible_port,omitempty"`
}
type rawGroup struct {
Hosts map[string]interface{} `yaml:"hosts,omitempty"`
}
// Inventory manages the app-owned Ansible YAML inventory file.
type Inventory struct {
path string
Hosts []*Host
}
func Load(path string) (*Inventory, error) {
inv := &Inventory{path: path}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return inv, nil
}
if err != nil {
return nil, err
}
var raw rawInventory
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
hostGroup := map[string]string{}
for groupName, g := range raw.All.Children {
if g == nil {
continue
}
for hostName := range g.Hosts {
hostGroup[hostName] = groupName
}
}
for name, vars := range raw.All.Hosts {
h := &Host{
Name: name,
Group: hostGroup[name],
DriftState: "unknown",
}
if vars != nil {
h.AnsibleHost = vars.AnsibleHost
h.AnsibleUser = vars.AnsibleUser
h.AnsiblePort = vars.AnsiblePort
}
inv.Hosts = append(inv.Hosts, h)
}
sort.Slice(inv.Hosts, func(i, j int) bool {
return inv.Hosts[i].Name < inv.Hosts[j].Name
})
return inv, nil
}
func (inv *Inventory) Add(h *Host) error {
for _, existing := range inv.Hosts {
if existing.Name == h.Name {
return fmt.Errorf("host %q already exists", h.Name)
}
}
h.DriftState = "unknown"
inv.Hosts = append(inv.Hosts, h)
sort.Slice(inv.Hosts, func(i, j int) bool {
return inv.Hosts[i].Name < inv.Hosts[j].Name
})
return inv.save()
}
func (inv *Inventory) Update(h *Host) error {
for i, existing := range inv.Hosts {
if existing.Name == h.Name {
h.Reachable = existing.Reachable
h.DriftState = existing.DriftState
h.LastCheck = existing.LastCheck
h.LastApply = existing.LastApply
h.LastError = existing.LastError
inv.Hosts[i] = h
return inv.save()
}
}
return fmt.Errorf("host %q not found", h.Name)
}
func (inv *Inventory) Remove(name string) error {
for i, h := range inv.Hosts {
if h.Name == name {
inv.Hosts = append(inv.Hosts[:i], inv.Hosts[i+1:]...)
return inv.save()
}
}
return fmt.Errorf("host %q not found", name)
}
func (inv *Inventory) Groups() []string {
seen := map[string]bool{}
var groups []string
for _, h := range inv.Hosts {
if h.Group != "" && !seen[h.Group] {
seen[h.Group] = true
groups = append(groups, h.Group)
}
}
sort.Strings(groups)
return groups
}
func (inv *Inventory) save() error {
if err := os.MkdirAll(filepath.Dir(inv.path), 0o755); err != nil {
return err
}
raw := rawInventory{}
raw.All.Hosts = make(map[string]*rawHostVars)
raw.All.Children = make(map[string]*rawGroup)
for _, h := range inv.Hosts {
raw.All.Hosts[h.Name] = &rawHostVars{
AnsibleHost: h.AnsibleHost,
AnsibleUser: h.AnsibleUser,
AnsiblePort: h.AnsiblePort,
}
if h.Group != "" {
g := raw.All.Children[h.Group]
if g == nil {
g = &rawGroup{Hosts: make(map[string]interface{})}
raw.All.Children[h.Group] = g
}
g.Hosts[h.Name] = nil
}
}
data, err := yaml.Marshal(raw)
if err != nil {
return err
}
return os.WriteFile(inv.path, data, 0o644)
}
+61
View File
@@ -0,0 +1,61 @@
package playbooks
import (
"os"
"path/filepath"
"sort"
"strings"
)
var excludedNames = map[string]bool{
"requirements.yml": true,
"requirements.yaml": true,
}
var excludedPrefixes = []string{
"group_vars",
"host_vars",
}
// Discover scans dir for Ansible playbook files, excluding support files.
// Returns base filenames sorted alphabetically.
func Discover(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".yml" && ext != ".yaml" {
continue
}
if excluded(name) {
continue
}
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
func excluded(name string) bool {
lower := strings.ToLower(name)
if excludedNames[lower] {
return true
}
for _, prefix := range excludedPrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
+62
View File
@@ -0,0 +1,62 @@
package runner
import (
"bufio"
"bytes"
"context"
"io"
"os/exec"
"sync"
"ansibletui/internal/ansible"
)
// Run executes argv, streams each output line to lineCh (closed on return),
// and returns the exit code, combined stdout+stderr log, and any exec error.
func Run(ctx context.Context, argv []string, lineCh chan<- string) (exitCode int, log []byte, err error) {
defer close(lineCh)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
pr, pw := io.Pipe()
var buf bytes.Buffer
cmd.Stdout = io.MultiWriter(&buf, pw)
cmd.Stderr = io.MultiWriter(&buf, pw)
if startErr := cmd.Start(); startErr != nil {
return -1, nil, startErr
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(pr)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
lineCh <- scanner.Text()
}
}()
runErr := cmd.Wait()
pw.Close()
wg.Wait()
code := 0
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
code = exitErr.ExitCode()
} else {
code = -1
}
}
return code, buf.Bytes(), runErr
}
// Ping checks whether a host is reachable via the ansible ping module.
func Ping(ctx context.Context, inventoryPath, host string) (bool, error) {
argv := ansible.BuildPingArgs(inventoryPath, host)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
err := cmd.Run()
return err == nil, err
}
+209
View File
@@ -0,0 +1,209 @@
package ui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/inventory"
)
var formFieldLabels = [5]string{
"Hostname *",
"Group",
"Ansible Host",
"Ansible User",
"Port",
}
// openAddServer opens the add/edit server form.
// Pass nil h to add a new server; pass an existing *inventory.Host to edit.
func (a *App) openAddServerScreen(h *inventory.Host) {
a.screen = ScreenAddServer
a.formErr = ""
a.formFocus = 0
a.editingHost = h
placeholders := [5]string{"media01", "media", "192.168.1.10", "frank", "22"}
for i := range a.formFields {
a.formFields[i].SetValue("")
a.formFields[i].Placeholder = placeholders[i]
a.formFields[i].Blur()
}
a.formFields[0].Focus()
if h != nil {
a.formFields[0].SetValue(h.Name)
a.formFields[1].SetValue(h.Group)
a.formFields[2].SetValue(h.AnsibleHost)
a.formFields[3].SetValue(h.AnsibleUser)
if h.AnsiblePort > 0 {
a.formFields[4].SetValue(strconv.Itoa(h.AnsiblePort))
}
// Hostname is not editable in edit mode
a.formFields[0].Blur()
a.formFocus = 1
a.formFields[1].Focus()
}
}
func (a *App) updateAddServer(msg tea.Msg) (tea.Model, tea.Cmd) {
km, ok := msg.(tea.KeyMsg)
if !ok {
// Forward to active text input
var cmd tea.Cmd
a.formFields[a.formFocus], cmd = a.formFields[a.formFocus].Update(msg)
return a, cmd
}
switch {
case key.Matches(km, keys.Back):
a.screen = ScreenHome
a.editingHost = nil
return a, nil
case key.Matches(km, keys.Tab), key.Matches(km, keys.Down):
a.formFocus = (a.formFocus + 1) % 5
a.refocusForm()
return a, nil
case key.Matches(km, keys.ShiftTab), key.Matches(km, keys.Up):
a.formFocus = (a.formFocus + 4) % 5
a.refocusForm()
return a, nil
case key.Matches(km, keys.Enter):
if a.formFocus < 4 {
a.formFocus++
a.refocusForm()
return a, nil
}
// Last field — submit
return a.submitForm()
}
var cmd tea.Cmd
a.formFields[a.formFocus], cmd = a.formFields[a.formFocus].Update(msg)
return a, cmd
}
func (a *App) refocusForm() {
for i := range a.formFields {
a.formFields[i].Blur()
}
// Don't allow editing the hostname in edit mode
if a.editingHost != nil && a.formFocus == 0 {
a.formFocus = 1
}
a.formFields[a.formFocus].Focus()
}
func (a *App) submitForm() (tea.Model, tea.Cmd) {
name := strings.TrimSpace(a.formFields[0].Value())
group := strings.TrimSpace(a.formFields[1].Value())
ansibleHost := strings.TrimSpace(a.formFields[2].Value())
ansibleUser := strings.TrimSpace(a.formFields[3].Value())
portStr := strings.TrimSpace(a.formFields[4].Value())
if name == "" {
a.formErr = "hostname is required"
return a, nil
}
if strings.ContainsAny(name, " \t") {
a.formErr = "hostname must not contain spaces"
return a, nil
}
port := 0
if portStr != "" {
p, err := strconv.Atoi(portStr)
if err != nil || p < 1 || p > 65535 {
a.formErr = "port must be a number between 1 and 65535"
return a, nil
}
port = p
}
h := &inventory.Host{
Name: name,
Group: group,
AnsibleHost: ansibleHost,
AnsibleUser: ansibleUser,
AnsiblePort: port,
}
var err error
if a.editingHost != nil {
h.Name = a.editingHost.Name
err = a.inv.Update(h)
} else {
err = a.inv.Add(h)
}
if err != nil {
a.formErr = err.Error()
return a, nil
}
action := "added"
if a.editingHost != nil {
action = "updated"
}
a.statusMsg = fmt.Sprintf("%s %s", action, name)
a.editingHost = nil
a.screen = ScreenHome
return a, nil
}
// ---- View ----
func (a *App) viewAddServer() string {
title := "Add Server"
if a.editingHost != nil {
title = fmt.Sprintf("Edit Server — %s", a.editingHost.Name)
}
dialogW := 60
if a.width < dialogW+4 {
dialogW = a.width - 4
}
header := formTitleStyle.Render(title)
var rows []string
for i, fi := range a.formFields {
label := formLabelStyle.Render(formFieldLabels[i])
input := fi.View()
if a.editingHost != nil && i == 0 {
input = subtleStyle.Render(a.formFields[0].Value() + " (not editable)")
}
rows = append(rows, label+input)
}
errLine := ""
if a.formErr != "" {
errLine = "\n" + formErrorStyle.Render("✗ "+a.formErr)
}
hints := hintKeyStyle.Render("Tab") + hintDescStyle.Render(" next field ") +
hintKeyStyle.Render("Enter") + hintDescStyle.Render(" confirm ") +
hintKeyStyle.Render("Esc") + hintDescStyle.Render(" cancel")
content := header + "\n\n" +
strings.Join(rows, "\n") +
errLine + "\n\n" +
dimStyle.Render(hints)
dialog := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorCyan).
Padding(1, 2).
Width(dialogW).
Render(content)
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, dialog)
}
+479
View File
@@ -0,0 +1,479 @@
package ui
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
"ansibletui/internal/playbooks"
"ansibletui/internal/runner"
)
type Screen int
const (
ScreenHome Screen = iota
ScreenAddServer
ScreenCmdFlow
ScreenRunDetails
)
const (
PanelServers = 0
PanelRuns = 1
)
// Sidebar tabs
const (
TabServers = 0
TabJobs = 1
TabConfig = 2
)
// Command-flow steps
const (
StepPlaybook = 0
StepMode = 1
StepPreview = 2
StepExecuting = 3
StepDone = 4
)
// App is the root Bubble Tea model.
type App struct {
// Infrastructure
cfg *config.Config
inv *inventory.Inventory
hist *history.History
// Terminal dimensions
width int
height int
// Active screen
screen Screen
// --- Home screen state ---
sidebarTab int
serverCursor int
runCursor int
activePanel int
filterInput textinput.Model
filtering bool
filterStr string
runs []*history.RunRecord
// --- Add/Edit server form ---
// formFields order: name, group, ansible_host, ansible_user, ansible_port
editingHost *inventory.Host
formFields [5]textinput.Model
formFocus int
formErr string
// --- Command flow ---
flowHost string
flowPlaybook string
flowMode ansible.Mode
flowStep int
flowArgv []string
playbookList []string
pbCursor int
modeCursor int
outputLines []string
outputVp viewport.Model
flowRecord *history.RunRecord
flowLog []byte
runCh <-chan tea.Msg
cancelRun context.CancelFunc
// --- Run details ---
viewingRun *history.RunRecord
logVp viewport.Model
// Status / error messages
statusMsg string
errMsg string
}
// New constructs the initial App model.
func New(cfg *config.Config, inv *inventory.Inventory, hist *history.History) *App {
placeholders := [5]string{"media01", "media", "192.168.1.10", "frank", "22"}
var fields [5]textinput.Model
for i := range fields {
ti := textinput.New()
ti.Placeholder = placeholders[i]
fields[i] = ti
}
fi := textinput.New()
fi.Placeholder = "filter..."
fi.Prompt = "/ "
return &App{
cfg: cfg,
inv: inv,
hist: hist,
formFields: fields,
filterInput: fi,
}
}
// ---- tea.Model interface ----
func (a *App) Init() tea.Cmd {
return loadRunsCmd(a.hist)
}
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
vpH := a.height - 12
if vpH < 5 {
vpH = 5
}
a.outputVp = viewport.New(a.width-4, vpH)
a.logVp = viewport.New(a.width-4, vpH)
return a, nil
case runsLoadedMsg:
a.runs = msg.runs
return a, nil
case pingResultMsg:
for _, h := range a.inv.Hosts {
if h.Name == msg.host {
reach := msg.reachable
h.Reachable = &reach
break
}
}
if msg.reachable {
a.statusMsg = fmt.Sprintf("%s is reachable", msg.host)
a.errMsg = ""
} else {
errStr := ""
if msg.err != nil {
errStr = msg.err.Error()
}
if strings.Contains(errStr, "executable file not found") ||
strings.Contains(errStr, "not found in $PATH") ||
strings.Contains(errStr, "cannot find the file") {
a.errMsg = "ansible not found in PATH — run from WSL or install Ansible"
} else {
a.errMsg = fmt.Sprintf("ping %s failed: %v", msg.host, msg.err)
}
a.statusMsg = ""
}
return a, nil
case outputLineMsg:
a.outputLines = append(a.outputLines, msg.line)
a.outputVp.SetContent(strings.Join(a.outputLines, "\n"))
a.outputVp.GotoBottom()
return a, waitRun(a.runCh)
case tea.MouseMsg:
return a.handleMouse(msg)
case runDoneMsg:
a.flowRecord = msg.record
a.flowLog = msg.log
a.flowStep = StepDone
if msg.err != nil {
a.errMsg = fmt.Sprintf("run error: %v", msg.err)
}
if msg.record != nil {
for _, h := range a.inv.Hosts {
if h.Name == a.flowHost || a.flowHost == "all" {
switch msg.record.Status {
case "clean", "drift":
h.DriftState = msg.record.Status
h.LastCheck = time.Now().Format("15:04")
case "ok":
h.DriftState = "clean"
h.LastApply = time.Now().Format("15:04")
}
}
}
}
return a, loadRunsCmd(a.hist)
}
switch a.screen {
case ScreenHome:
return a.updateHome(msg)
case ScreenAddServer:
return a.updateAddServer(msg)
case ScreenCmdFlow:
return a.updateCmdFlow(msg)
case ScreenRunDetails:
return a.updateRunDetails(msg)
}
return a, nil
}
func (a *App) View() string {
if a.width == 0 {
return "Loading…"
}
switch a.screen {
case ScreenHome:
return a.viewHome()
case ScreenAddServer:
return a.viewAddServer()
case ScreenCmdFlow:
return a.viewCmdFlow()
case ScreenRunDetails:
return a.viewRunDetails()
}
return ""
}
// ---- Shared commands ----
func loadRunsCmd(hist *history.History) tea.Cmd {
return func() tea.Msg {
runs, _ := hist.List(20)
return runsLoadedMsg{runs: runs}
}
}
func pingCmd(inventoryPath, host string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ok, err := runner.Ping(ctx, inventoryPath, host)
return pingResultMsg{host: host, reachable: ok, err: err}
}
}
func waitRun(ch <-chan tea.Msg) tea.Cmd {
if ch == nil {
return nil
}
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return nil
}
return msg
}
}
// startRun launches a playbook run goroutine and returns the first waitRun Cmd.
func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode) tea.Cmd {
ch := make(chan tea.Msg, 256)
ctx, cancel := context.WithCancel(context.Background())
a.cancelRun = cancel
a.runCh = ch
hist := a.hist
inv := a.inv
go func() {
defer close(ch)
lineCh := make(chan string, 256)
start := time.Now()
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
go func() {
code, log, err := runner.Run(ctx, argv, lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
ch <- outputLineMsg{line: line}
}
r := <-resCh
end := time.Now()
rec := &history.RunRecord{
Playbook: playbook,
Host: host,
Mode: mode.String(),
StartTime: start,
EndTime: end,
ExitCode: r.code,
}
recaps := ansible.ParseRecap(string(r.log))
if len(recaps) > 0 {
rc := recaps[0]
rec.OK = rc.OK
rec.Changed = rc.Changed
rec.Failed = rc.Failed
rec.Unreachable = rc.Unreachable
rec.Skipped = rc.Skipped
rec.Status = rc.DriftState()
for _, h := range inv.Hosts {
if h.Name == host || host == "all" {
h.DriftState = rc.DriftState()
}
}
} else if r.err != nil {
rec.Status = "failed"
} else {
rec.Status = "ok"
}
_ = hist.Save(rec, r.log)
ch <- runDoneMsg{record: rec, log: r.log, err: r.err}
}()
return waitRun(ch)
}
// ---- Helpers ----
func (a *App) filteredServers() []*inventory.Host {
if a.filterStr == "" {
return a.inv.Hosts
}
f := strings.ToLower(a.filterStr)
var out []*inventory.Host
for _, h := range a.inv.Hosts {
if strings.Contains(strings.ToLower(h.Name), f) ||
strings.Contains(strings.ToLower(h.Group), f) {
out = append(out, h)
}
}
return out
}
func (a *App) selectedServer() *inventory.Host {
hosts := a.filteredServers()
if len(hosts) == 0 || a.serverCursor >= len(hosts) {
return nil
}
return hosts[a.serverCursor]
}
func (a *App) selectedRunRecord() *history.RunRecord {
if len(a.runs) == 0 || a.runCursor >= len(a.runs) {
return nil
}
return a.runs[a.runCursor]
}
func (a *App) loadPlaybooks() {
pbs, _ := playbooks.Discover(a.cfg.PlaybookDir)
a.playbookList = pbs
a.pbCursor = 0
// Set cursor to recent playbook if available
for i, pb := range pbs {
if pb == a.cfg.RecentPlaybook {
a.pbCursor = i
break
}
}
}
// ---- Mouse handling ----
// handleMouse routes mouse events to the active screen's handler.
func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch a.screen {
case ScreenHome:
return a.handleHomeMouseMsg(msg)
case ScreenRunDetails:
// viewport handles its own scrolling
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
return a, cmd
case ScreenCmdFlow:
if a.flowStep == StepExecuting || a.flowStep == StepDone {
var cmd tea.Cmd
a.outputVp, cmd = a.outputVp.Update(msg)
return a, cmd
}
}
return a, nil
}
// homeLayout returns approximate screen coordinates for the home layout.
// These are estimates; they are intentionally simple so row selection works
// at typical terminal sizes without precise measurement.
func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, runX0 int) {
const titleLines = 1
const topRowLines = 6 // fleet+actions panels rendered height (border+content)
const panelHeaderLines = 3 // header + MarginBottom + col-headers + divider
serverRowY0 = titleLines + topRowLines + panelHeaderLines
sideW := 6
mainW := a.width - sideW - 1
serversW := mainW * 65 / 100
serverX0 = sideW + 2 // sidebar + border + padding
serverX1 = sideW + serversW
runX0 = serverX1 + 2
return
}
func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
serverRowY0, serverX0, serverX1, runX0 := a.homeLayout()
switch msg.Button {
case tea.MouseButtonWheelUp:
a.moveCursorUp()
case tea.MouseButtonWheelDown:
a.moveCursorDown()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
break
}
x, y := msg.X, msg.Y
if x >= serverX0 && x < serverX1 {
// Clicked in the servers panel
a.activePanel = PanelServers
row := y - serverRowY0
hosts := a.filteredServers()
if row >= 0 && row < len(hosts) {
a.serverCursor = row
}
} else if x >= runX0 {
// Clicked in the recent runs panel
a.activePanel = PanelRuns
// Each run entry is 3 lines: name, host/status, blank
row := (y - serverRowY0) / 3
if row >= 0 && row < len(a.runs) {
a.runCursor = row
}
}
}
return a, nil
}
// padRight pads s to exactly width runes (truncates if longer).
func padRight(s string, width int) string {
r := []rune(s)
if len(r) >= width {
if width <= 1 {
return string(r[:width])
}
return string(r[:width-1]) + "…"
}
return s + strings.Repeat(" ", width-len(r))
}
+308
View File
@@ -0,0 +1,308 @@
package ui
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
)
var modeOptions = []ansible.Mode{
ansible.ModeCheck,
ansible.ModeCheckDiff,
ansible.ModeApply,
}
func (a *App) updateCmdFlow(msg tea.Msg) (tea.Model, tea.Cmd) {
// During execution: forward scroll keys to viewport, but also handle cancel
if a.flowStep == StepExecuting || a.flowStep == StepDone {
if km, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(km, keys.Back):
if a.flowStep == StepExecuting && a.cancelRun != nil {
a.cancelRun()
a.cancelRun = nil
a.statusMsg = "run cancelled"
}
if a.flowStep == StepDone {
a.screen = ScreenHome
}
return a, nil
case key.Matches(km, keys.Quit):
if a.flowStep == StepDone {
a.screen = ScreenHome
}
return a, nil
}
}
var cmd tea.Cmd
a.outputVp, cmd = a.outputVp.Update(msg)
return a, cmd
}
km, ok := msg.(tea.KeyMsg)
if !ok {
return a, nil
}
switch {
case key.Matches(km, keys.Back):
if a.flowStep == StepPlaybook {
a.screen = ScreenHome
} else {
a.flowStep--
}
return a, nil
case key.Matches(km, keys.Quit):
a.screen = ScreenHome
return a, nil
case key.Matches(km, keys.Up):
switch a.flowStep {
case StepPlaybook:
if a.pbCursor > 0 {
a.pbCursor--
}
case StepMode:
if a.modeCursor > 0 {
a.modeCursor--
}
}
return a, nil
case key.Matches(km, keys.Down):
switch a.flowStep {
case StepPlaybook:
if a.pbCursor < len(a.playbookList)-1 {
a.pbCursor++
}
case StepMode:
if a.modeCursor < len(modeOptions)-1 {
a.modeCursor++
}
}
return a, nil
case key.Matches(km, keys.Enter):
switch a.flowStep {
case StepPlaybook:
if len(a.playbookList) == 0 {
a.formErr = fmt.Sprintf("no playbooks found in %s", a.cfg.PlaybookDir)
return a, nil
}
a.flowPlaybook = a.playbookList[a.pbCursor]
a.cfg.RecentPlaybook = a.flowPlaybook
_ = a.cfg.Save()
a.flowStep = StepMode
case StepMode:
a.flowMode = modeOptions[a.modeCursor]
a.flowArgv = ansible.BuildPlaybookArgs(
a.cfg.InventoryPath,
filepath.Join(a.cfg.PlaybookDir, a.flowPlaybook),
a.flowHost,
a.flowMode,
)
a.flowStep = StepPreview
case StepPreview:
a.flowStep = StepExecuting
a.outputLines = nil
a.outputVp.SetContent("")
return a, a.startRun(a.flowArgv, a.flowPlaybook, a.flowHost, a.flowMode)
}
return a, nil
}
return a, nil
}
func (a *App) viewCmdFlow() string {
w := a.width - 2
if w < 20 {
w = 20
}
title := titleStyle.Render(fmt.Sprintf("ansibleTUI / Check + Apply — %s", a.flowHost))
bar := a.renderStepBar()
var body string
switch a.flowStep {
case StepPlaybook:
body = a.renderPlaybookStep(w)
case StepMode:
body = a.renderModeStep(w)
case StepPreview:
body = a.renderPreviewStep(w)
case StepExecuting:
body = a.renderExecutingStep(w)
case StepDone:
body = a.renderDoneStep(w)
}
footer := a.renderCmdFlowFooter(w)
return lipgloss.JoinVertical(lipgloss.Left, " "+title, bar, body, footer)
}
func (a *App) renderStepBar() string {
steps := []string{"1 Playbook", "2 Mode", "3 Preview", "4 Execute"}
var parts []string
for i, s := range steps {
if i == a.flowStep {
parts = append(parts, navActiveStyle.Render("▶ "+s))
} else if i < a.flowStep {
parts = append(parts, runCleanStyle.Render("✓ "+s))
} else {
parts = append(parts, subtleStyle.Render(" "+s))
}
if i < len(steps)-1 {
parts = append(parts, dimStyle.Render(" → "))
}
}
return " " + strings.Join(parts, "") + "\n"
}
func (a *App) renderPlaybookStep(w int) string {
header := panelHeaderStyle.Render("Select playbook")
if len(a.playbookList) == 0 {
msg := formErrorStyle.Render("No playbooks found in: " + a.cfg.PlaybookDir)
hint := "\n" + dimStyle.Render("Set playbook_dir in ~/.config/ansibletui/config.yaml")
return panelStyle.Width(w - 2).Render(header + "\n" + msg + hint)
}
var rows []string
for i, pb := range a.playbookList {
if i == a.pbCursor {
rows = append(rows, tableRowSelected.Render(" ▶ "+padRight(pb, w-10)))
} else {
rows = append(rows, " "+subtleStyle.Render(pb))
}
}
if a.formErr != "" {
rows = append(rows, "\n"+formErrorStyle.Render(a.formErr))
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(rows, "\n"))
}
func (a *App) renderModeStep(w int) string {
header := panelHeaderStyle.Render(fmt.Sprintf("Select mode — %s", a.flowPlaybook))
var rows []string
for i, m := range modeOptions {
label := m.ModeLabel()
var desc string
switch m {
case ansible.ModeCheck:
desc = "Simulate run, report what would change"
case ansible.ModeCheckDiff:
desc = "Simulate run and show file diffs"
case ansible.ModeApply:
desc = "Live apply — changes will be made!"
}
if i == a.modeCursor {
rows = append(rows, tableRowSelected.Render(fmt.Sprintf(" ▶ %-18s %s", label, desc)))
} else {
rows = append(rows, " "+subtleStyle.Render(fmt.Sprintf("%-18s", label))+" "+dimStyle.Render(desc))
}
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(rows, "\n"))
}
func (a *App) renderPreviewStep(w int) string {
header := panelHeaderStyle.Render("Command preview")
modeLabel := boldStyle.Render(a.flowMode.ModeLabel())
argv := strings.Join(a.flowArgv, " ")
if len(argv) > w-8 {
argv = argv[:w-8] + "…"
}
cmd := lipgloss.NewStyle().
Background(lipgloss.Color("#111111")).
Foreground(colorCyan).
Padding(0, 1).
Render("$ " + argv)
warning := ""
if a.flowMode == ansible.ModeApply {
warning = "\n" + formErrorStyle.Render("⚠ This is a live run — changes will be applied to "+a.flowHost)
}
content := header + "\n" +
subtleStyle.Render("Mode: ") + modeLabel + "\n\n" +
cmd + warning + "\n\n" +
dimStyle.Render("Press Enter to execute, Esc to go back")
return panelStyle.Width(w - 2).Render(content)
}
func (a *App) renderExecutingStep(w int) string {
header := panelHeaderStyle.Render(
fmt.Sprintf("Running — %s on %s", a.flowPlaybook, a.flowHost),
)
spinner := navActiveStyle.Render("⠿ ")
a.outputVp.Width = w - 6
vpH := a.height - 14
if vpH < 3 {
vpH = 3
}
a.outputVp.Height = vpH
content := header + "\n" + spinner + subtleStyle.Render("executing…") + "\n\n" + a.outputVp.View()
return panelStyle.Width(w - 2).Render(content)
}
func (a *App) renderDoneStep(w int) string {
rec := a.flowRecord
if rec == nil {
return panelStyle.Width(w - 2).Render(formErrorStyle.Render("Run failed — no result recorded."))
}
statusStr := renderRunStatus(rec.Status)
header := panelHeaderStyle.Render("Run complete — " + rec.Playbook)
recap := fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d",
rec.OK, rec.Changed, rec.Failed, rec.Unreachable, rec.Skipped)
a.outputVp.Width = w - 6
vpH := a.height - 18
if vpH < 3 {
vpH = 3
}
a.outputVp.Height = vpH
content := header + "\n" +
subtleStyle.Render("Status: ") + statusStr + "\n" +
subtleStyle.Render(recap) + "\n\n" +
a.outputVp.View() + "\n\n" +
dimStyle.Render("Press Esc to return to home")
return panelStyle.Width(w - 2).Render(content)
}
func (a *App) renderCmdFlowFooter(w int) string {
var hints string
switch a.flowStep {
case StepExecuting:
hints = hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("cancel")
case StepDone:
hints = hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back to home")
default:
hints = hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select") + " " +
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("confirm") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back")
}
return lipgloss.NewStyle().
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Width(w).
Padding(0, 1).
Render(hints)
}
+568
View File
@@ -0,0 +1,568 @@
package ui
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
)
// ---- Update ----
func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
// Filter input is active — route all keys there
if a.filtering {
return a.updateFilter(msg)
}
km, ok := msg.(tea.KeyMsg)
if !ok {
return a, nil
}
switch {
case key.Matches(km, keys.Quit):
return a, tea.Quit
case key.Matches(km, keys.Tab):
a.sidebarTab = (a.sidebarTab + 1) % 3
return a, nil
case key.Matches(km, keys.ShiftTab):
a.sidebarTab = (a.sidebarTab + 2) % 3
return a, nil
// Panel switch with Left/Right — clear messages when switching panels
case key.Matches(km, keys.Left):
a.activePanel = PanelServers
return a, nil
case key.Matches(km, keys.Right):
a.activePanel = PanelRuns
return a, nil
case key.Matches(km, keys.Up):
a.moveCursorUp()
return a, nil
case key.Matches(km, keys.Down):
a.moveCursorDown()
return a, nil
case key.Matches(km, keys.Filter):
a.filtering = true
a.filterInput.Focus()
a.filterInput.SetValue("")
return a, nil
case key.Matches(km, keys.Add):
a.openAddServerScreen(nil)
return a, nil
case key.Matches(km, keys.Edit):
if h := a.selectedServer(); h != nil {
a.openAddServerScreen(h)
}
return a, nil
case key.Matches(km, keys.Delete):
if h := a.selectedServer(); h != nil {
if err := a.inv.Remove(h.Name); err != nil {
a.errMsg = err.Error()
} else {
a.statusMsg = fmt.Sprintf("removed %s", h.Name)
if a.serverCursor > 0 {
a.serverCursor--
}
}
}
return a, nil
case key.Matches(km, keys.Ping):
if h := a.selectedServer(); h != nil {
a.statusMsg = fmt.Sprintf("pinging %s…", h.Name)
return a, pingCmd(a.cfg.InventoryPath, h.Name)
}
return a, nil
case key.Matches(km, keys.Check):
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeCheckDiff)
}
return a, nil
case key.Matches(km, keys.Apply):
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeApply)
}
return a, nil
case key.Matches(km, keys.Enter):
if a.activePanel == PanelRuns {
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
} else {
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeCheckDiff)
}
}
return a, nil
}
return a, nil
}
func (a *App) updateFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
km, ok := msg.(tea.KeyMsg)
if ok {
switch km.String() {
case "esc", "enter":
a.filtering = false
a.filterStr = a.filterInput.Value()
a.filterInput.Blur()
a.serverCursor = 0
return a, nil
}
}
var cmd tea.Cmd
a.filterInput, cmd = a.filterInput.Update(msg)
a.filterStr = a.filterInput.Value()
a.serverCursor = 0
return a, cmd
}
func (a *App) moveCursorUp() {
if a.activePanel == PanelServers {
if a.serverCursor > 0 {
a.serverCursor--
}
} else {
if a.runCursor > 0 {
a.runCursor--
}
}
}
func (a *App) moveCursorDown() {
if a.activePanel == PanelServers {
max := len(a.filteredServers()) - 1
if a.serverCursor < max {
a.serverCursor++
}
} else {
if a.runCursor < len(a.runs)-1 {
a.runCursor++
}
}
}
func (a *App) openCmdFlow(host string, defaultMode ansible.Mode) {
a.screen = ScreenCmdFlow
a.flowHost = host
a.flowMode = defaultMode
a.flowStep = StepPlaybook
a.outputLines = nil
a.flowRecord = nil
a.flowLog = nil
a.modeCursor = int(defaultMode)
a.loadPlaybooks()
}
// ---- View ----
func (a *App) viewHome() string {
mainW := a.width - 2
if mainW < 20 {
mainW = 20
}
titleBar := a.renderTitleBar(mainW)
body := a.renderBody(mainW)
footer := a.renderFooter(mainW)
return lipgloss.JoinVertical(lipgloss.Left, titleBar, body, footer)
}
func (a *App) renderTitleBar(w int) string {
title := titleStyle.Render("ansibleTUI / Home")
inv := pathStyle.Render("inventory: " + shorten(a.cfg.InventoryPath))
pbs := pathStyle.Render("playbooks: " + shorten(a.cfg.PlaybookDir))
right := inv + " " + pbs
gap := w - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return " " + title + strings.Repeat(" ", gap) + right
}
func (a *App) renderBody(w int) string {
sideW := 6
mainW := w - sideW - 1
if mainW < 10 {
mainW = 10
}
sidebar := a.renderSidebar(sideW)
main := a.renderMainContent(mainW)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main)
}
func (a *App) renderSidebar(w int) string {
tabs := []string{"SERV", "JOBS", "CFG"}
var lines []string
lines = append(lines, "") // top padding
for i, t := range tabs {
if i == a.sidebarTab {
lines = append(lines, navActiveStyle.Width(w).Render(t))
} else {
lines = append(lines, navStyle.Width(w).Render(t))
}
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func (a *App) renderMainContent(w int) string {
switch a.sidebarTab {
case TabJobs:
return a.renderJobsTab(w)
case TabConfig:
return a.renderConfigTab(w)
default:
return a.renderServersTab(w)
}
}
func (a *App) renderServersTab(w int) string {
// Top row: Fleet Summary | Quick Actions
topH := 7
fleetW := w * 33 / 100
if fleetW < 28 {
fleetW = 28
}
actionW := w - fleetW - 1
fleet := a.renderFleetSummary(fleetW, topH)
actions := a.renderQuickActions(actionW, topH)
topRow := lipgloss.JoinHorizontal(lipgloss.Top, fleet, " ", actions)
// Bottom row: Servers table | Recent runs
bottomH := a.height - topH - 5 // leave room for title, footer, status
if bottomH < 6 {
bottomH = 6
}
serversW := w * 65 / 100
if serversW < 40 {
serversW = 40
}
runsW := w - serversW - 1
servers := a.renderServersPanel(serversW, bottomH)
runs := a.renderRecentRunsPanel(runsW, bottomH)
bottomRow := lipgloss.JoinHorizontal(lipgloss.Top, servers, " ", runs)
statusBar := a.renderStatusBar(w)
return lipgloss.JoinVertical(lipgloss.Left, topRow, bottomRow, statusBar)
}
func (a *App) renderFleetSummary(w, h int) string {
total := len(a.inv.Hosts)
var nClean, nDrift, nFailed int
for _, h := range a.inv.Hosts {
switch h.DriftState {
case "clean":
nClean++
case "drift":
nDrift++
case "failed", "unreachable":
nFailed++
}
}
header := panelHeaderStyle.Render("Fleet Summary")
badges := lipgloss.JoinHorizontal(lipgloss.Top,
badgeHostsStyle.Render(fmt.Sprintf("%d hosts", total))+" ",
badgeCleanStyle.Render(fmt.Sprintf("%d clean", nClean))+" ",
badgeDriftStyle.Render(fmt.Sprintf("%d drift", nDrift))+" ",
badgeFailedStyle.Render(fmt.Sprintf("%d failed", nFailed)),
)
content := header + "\n" + badges
innerW := w - 4 // account for border+padding
if innerW < 1 {
innerW = 1
}
_ = h
return panelStyle.Width(innerW).Render(content)
}
func (a *App) renderQuickActions(w, h int) string {
header := panelHeaderStyle.Render("Quick Actions")
btn := func(label string) string {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(0, 1).
Foreground(colorSubtle).
Render(label)
}
btns := lipgloss.JoinHorizontal(lipgloss.Top,
btn("Check Selected")+" ",
btn("Apply Drifted")+" ",
btn("Add Server")+" ",
btn("Open Logs"),
)
preview := ""
if h := a.selectedServer(); h != nil && a.cfg.RecentPlaybook != "" {
argv := ansible.BuildPlaybookArgs(
a.cfg.InventoryPath,
filepath.Join(a.cfg.PlaybookDir, a.cfg.RecentPlaybook),
h.Name,
ansible.ModeCheckDiff,
)
cmdStr := strings.Join(argv, " ")
if len(cmdStr) > w-8 {
cmdStr = cmdStr[:w-8] + "…"
}
preview = "\n" + dimStyle.Render("$ "+cmdStr)
}
content := header + "\n" + btns + preview
innerW := w - 4
if innerW < 1 {
innerW = 1
}
_ = h
return panelStyle.Width(innerW).Render(content)
}
func (a *App) renderServersPanel(w, h int) string {
header := panelHeaderStyle.Render("Servers")
// Column widths — scale with panel width
colHost := 16
colGroup := 12
colReach := 10
colDrift := 10
colCheck := 10
colApply := 10
// Header row
hdr := tableHeaderStyle.Render(
padRight("Host", colHost)+" "+
padRight("Group", colGroup)+" "+
padRight("Reachable", colReach)+" "+
padRight("Drift", colDrift)+" "+
padRight("Last Check", colCheck)+" "+
padRight("Last Apply", colApply),
)
divider := dividerStyle.Render(strings.Repeat("─", w-4))
hosts := a.filteredServers()
if a.serverCursor >= len(hosts) && len(hosts) > 0 {
a.serverCursor = len(hosts) - 1
}
visibleH := h - 7 // header + borders + divider
if visibleH < 1 {
visibleH = 1
}
var rows []string
// Scroll offset
start := 0
if a.serverCursor >= visibleH {
start = a.serverCursor - visibleH + 1
}
for i := start; i < len(hosts) && i < start+visibleH; i++ {
hst := hosts[i]
reachStr := renderReachable(hst.Reachable)
driftStr := renderDrift(hst.DriftState)
check := subtleStyle.Render(padRight(hst.LastCheck, colCheck))
apply := subtleStyle.Render(padRight(hst.LastApply, colApply))
// Build the plain-text parts (for width calculation)
plainRow := padRight(hst.Name, colHost) + " " +
padRight(hst.Group, colGroup) + " "
// Styled status columns
reachPad := padRight("", colReach-lipgloss.Width(reachStr))
driftPad := padRight("", colDrift-lipgloss.Width(driftStr))
row := plainRow + reachStr + reachPad + " " + driftStr + driftPad + " " + check + " " + apply
if i == a.serverCursor && a.activePanel == PanelServers {
// Highlight: wrap plain parts only (lipgloss handles ANSI reset)
row = tableRowSelected.Render(padRight(hst.Name, colHost)+" "+padRight(hst.Group, colGroup)+" ") +
reachStr + reachPad + " " + driftStr + driftPad + " " + check + " " + apply
}
rows = append(rows, row)
}
if len(hosts) == 0 {
rows = append(rows, subtleStyle.Render("No servers. Press 'a' to add one."))
}
content := header + "\n" + hdr + "\n" + divider + "\n" + strings.Join(rows, "\n")
innerW := w - 4
if innerW < 1 {
innerW = 1
}
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
func (a *App) renderRecentRunsPanel(w, h int) string {
header := panelHeaderStyle.Render("Recent Runs")
var lines []string
for i, run := range a.runs {
if i >= h-6 {
break
}
timeStr := runTimeStyle.Render(padRight(run.TimeLabel(), 5))
pbStr := runPlaybookStyle.Render(run.Playbook)
line1 := timeStr + " " + pbStr
hostMode := subtleStyle.Render(padRight(run.Host+" / "+run.Mode, w-10))
statusStr := run.StatusSummary()
var statusStyled string
switch run.Status {
case "drift":
statusStyled = runChangedStyle.Render(statusStr)
case "clean", "ok":
statusStyled = runCleanStyle.Render(statusStr)
case "failed":
statusStyled = runFailedStyle.Render(statusStr)
default:
statusStyled = runUnreachStyle.Render(statusStr)
}
line2 := hostMode + statusStyled
if i == a.runCursor && a.activePanel == PanelRuns {
line1 = runSelectedBg.Width(w - 6).Render(line1)
line2 = runSelectedBg.Width(w - 6).Render(line2)
}
lines = append(lines, line1, line2, "")
}
if len(a.runs) == 0 {
lines = append(lines, subtleStyle.Render("No runs yet."))
}
content := header + "\n" + strings.Join(lines, "\n")
innerW := w - 4
if innerW < 1 {
innerW = 1
}
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
func (a *App) renderStatusBar(w int) string {
if a.errMsg != "" {
return statusBarStyle.Width(w).Render(formErrorStyle.Render("✗ " + a.errMsg))
}
if a.statusMsg != "" {
return statusBarStyle.Width(w).Render(subtleStyle.Render(a.statusMsg))
}
h := a.selectedServer()
if h == nil {
return statusBarStyle.Width(w).Render("")
}
name := statusHostStyle.Render(h.Name)
ip := ""
if h.AnsibleHost != "" {
ip = " " + dimStyle.Render("("+h.AnsibleHost+")")
}
group := ""
if h.Group != "" {
group = " " + subtleStyle.Render(h.Group)
}
drift := ""
switch h.DriftState {
case "drift":
drift = " " + statusDrift.Render("drift detected")
case "clean":
drift = " " + statusClean.Render("clean")
case "failed", "unreachable":
drift = " " + statusFailed.Render(h.DriftState)
}
msg := statusBarStyle.Render("Selected: ") + name + ip + group + drift +
" " + dimStyle.Render("e edit d delete")
return lipgloss.NewStyle().Width(w).Render(msg)
}
func (a *App) renderFooter(w int) string {
hints := []string{
hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select"),
hintKeyStyle.Render("/") + " " + hintDescStyle.Render("filter"),
hintKeyStyle.Render("a") + " " + hintDescStyle.Render("add"),
hintKeyStyle.Render("e") + " " + hintDescStyle.Render("edit"),
hintKeyStyle.Render("p") + " " + hintDescStyle.Render("ping"),
hintKeyStyle.Render("c") + " " + hintDescStyle.Render("check"),
hintKeyStyle.Render("r") + " " + hintDescStyle.Render("apply"),
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("details"),
hintKeyStyle.Render("q") + " " + hintDescStyle.Render("quit"),
}
bar := strings.Join(hints, " ")
return lipgloss.NewStyle().
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Width(w).
Padding(0, 1).
Render(bar)
}
// Jobs / Config stub tabs
func (a *App) renderJobsTab(w int) string {
header := panelHeaderStyle.Render("All Runs")
var lines []string
for i, run := range a.runs {
line := fmt.Sprintf("%-5s %-22s %-12s %-10s %s",
run.TimeLabel(), run.Playbook, run.Host, run.Mode, run.StatusSummary())
if i == a.runCursor {
line = tableRowSelected.Render(line)
}
lines = append(lines, line)
}
if len(lines) == 0 {
lines = append(lines, subtleStyle.Render("No runs recorded yet."))
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(lines, "\n"))
}
func (a *App) renderConfigTab(w int) string {
header := panelHeaderStyle.Render("Configuration")
lines := []string{
subtleStyle.Render("Playbook dir: ") + a.cfg.PlaybookDir,
subtleStyle.Render("Inventory path: ") + a.cfg.InventoryPath,
subtleStyle.Render("Runtime: ") + a.cfg.Runtime,
subtleStyle.Render("Recent playbook:") + a.cfg.RecentPlaybook,
"",
dimStyle.Render("Edit ~/.config/ansibletui/config.yaml to change these settings."),
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(lines, "\n"))
}
// shorten abbreviates a home-relative path for display.
func shorten(p string) string {
return p
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Filter key.Binding
Add key.Binding
Edit key.Binding
Ping key.Binding
Check key.Binding
Apply key.Binding
Enter key.Binding
Back key.Binding
Quit key.Binding
Tab key.Binding
ShiftTab key.Binding
Delete key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k")),
Down: key.NewBinding(key.WithKeys("down", "j")),
Left: key.NewBinding(key.WithKeys("left", "h")),
Right: key.NewBinding(key.WithKeys("right", "l")),
Filter: key.NewBinding(key.WithKeys("/")),
Add: key.NewBinding(key.WithKeys("a")),
Edit: key.NewBinding(key.WithKeys("e")),
Ping: key.NewBinding(key.WithKeys("p")),
Check: key.NewBinding(key.WithKeys("c")),
Apply: key.NewBinding(key.WithKeys("r")),
Enter: key.NewBinding(key.WithKeys("enter")),
Back: key.NewBinding(key.WithKeys("esc")),
Quit: key.NewBinding(key.WithKeys("q")),
Tab: key.NewBinding(key.WithKeys("tab")),
ShiftTab: key.NewBinding(key.WithKeys("shift+tab")),
Delete: key.NewBinding(key.WithKeys("d")),
}
+27
View File
@@ -0,0 +1,27 @@
package ui
import "ansibletui/internal/history"
// pingResultMsg is delivered when an ansible ping completes.
type pingResultMsg struct {
host string
reachable bool
err error
}
// outputLineMsg carries one line of streamed playbook output.
type outputLineMsg struct {
line string
}
// runDoneMsg is delivered when a playbook run finishes.
type runDoneMsg struct {
record *history.RunRecord
log []byte
err error
}
// runsLoadedMsg carries a freshly loaded run history list.
type runsLoadedMsg struct {
runs []*history.RunRecord
}
+89
View File
@@ -0,0 +1,89 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/history"
)
func (a *App) openRunDetailsScreen(run *history.RunRecord) {
a.screen = ScreenRunDetails
a.viewingRun = run
logBytes, _ := a.hist.LoadLog(run)
a.logVp.SetContent(string(logBytes))
a.logVp.GotoTop()
}
func (a *App) updateRunDetails(msg tea.Msg) (tea.Model, tea.Cmd) {
if km, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(km, keys.Back), key.Matches(km, keys.Quit):
a.screen = ScreenHome
a.viewingRun = nil
return a, nil
}
}
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
return a, cmd
}
func (a *App) viewRunDetails() string {
run := a.viewingRun
if run == nil {
return "No run selected."
}
w := a.width - 2
if w < 20 {
w = 20
}
title := titleStyle.Render(fmt.Sprintf("ansibleTUI / Run Details — %s", run.Playbook))
statusStr := renderRunStatus(run.Status)
recap := fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d",
run.OK, run.Changed, run.Failed, run.Unreachable, run.Skipped)
meta := strings.Join([]string{
subtleStyle.Render("Playbook: ") + boldStyle.Render(run.Playbook),
subtleStyle.Render("Host: ") + boldStyle.Render(run.Host),
subtleStyle.Render("Mode: ") + boldStyle.Render(run.Mode),
subtleStyle.Render("Status: ") + statusStr,
subtleStyle.Render("Recap: ") + dimStyle.Render(recap),
subtleStyle.Render("Started: ") + run.StartTime.Format("2006-01-02 15:04:05"),
}, "\n")
a.logVp.Width = w - 4
vpH := a.height - 16
if vpH < 3 {
vpH = 3
}
a.logVp.Height = vpH
logHeader := panelHeaderStyle.Render("Output log")
logPanel := panelStyle.Width(w - 2).Render(logHeader + "\n" + a.logVp.View())
footer := lipgloss.NewStyle().
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Width(w).
Padding(0, 1).
Render(hintKeyStyle.Render("↑↓/PgUp/PgDn") + " " + hintDescStyle.Render("scroll") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back"))
return lipgloss.JoinVertical(lipgloss.Left,
" "+title,
" "+meta,
"",
logPanel,
footer,
)
}
+169
View File
@@ -0,0 +1,169 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Palette
var (
colorGreen = lipgloss.Color("#3ddc84")
colorYellow = lipgloss.Color("#ffbc00")
colorRed = lipgloss.Color("#ff5f57")
colorCyan = lipgloss.Color("#00ccee")
colorDim = lipgloss.Color("#707070")
colorSubtle = lipgloss.Color("#aaaaaa")
colorWhite = lipgloss.Color("#f0f0f0")
colorSelected = lipgloss.Color("#1a4070")
colorBorder = lipgloss.Color("#5a5a5a")
)
// Base text styles
var (
dimStyle = lipgloss.NewStyle().Foreground(colorDim)
subtleStyle = lipgloss.NewStyle().Foreground(colorSubtle)
boldStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
)
// Status badge styles
var (
statusOnline = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
statusFailed = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
statusClean = lipgloss.NewStyle().Foreground(colorGreen)
statusDrift = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
statusUnknown = lipgloss.NewStyle().Foreground(colorSubtle)
statusUnreachable = lipgloss.NewStyle().Foreground(colorRed)
)
// Panel / box styles
var (
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(0, 1)
panelHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorWhite).
MarginBottom(1)
)
// Sidebar
var (
navActiveStyle = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true)
navStyle = lipgloss.NewStyle().
Foreground(colorSubtle)
)
// Table
var (
tableHeaderStyle = lipgloss.NewStyle().
Foreground(colorSubtle)
tableRowSelected = lipgloss.NewStyle().
Background(colorSelected).
Foreground(colorWhite)
)
// Recent run list
var (
runTimeStyle = lipgloss.NewStyle().Foreground(colorSubtle)
runPlaybookStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
runSelectedBg = lipgloss.NewStyle().Background(colorSelected)
runChangedStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
runCleanStyle = lipgloss.NewStyle().Foreground(colorGreen)
runFailedStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
runUnreachStyle = lipgloss.NewStyle().Foreground(colorRed)
)
// Fleet summary badges
var (
badgeHostsStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#0d3358")).
Foreground(lipgloss.Color("#4dd9f5")).
Padding(0, 1).Bold(true)
badgeCleanStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#0d3320")).
Foreground(lipgloss.Color("#3ddc84")).
Padding(0, 1).Bold(true)
badgeDriftStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#3d2d00")).
Foreground(lipgloss.Color("#ffbc00")).
Padding(0, 1).Bold(true)
badgeFailedStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#3d0f0f")).
Foreground(lipgloss.Color("#ff5f57")).
Padding(0, 1).Bold(true)
)
// Title bar
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
pathStyle = lipgloss.NewStyle().Foreground(colorSubtle)
)
// Footer keyboard hints
var (
hintKeyStyle = lipgloss.NewStyle().Bold(true).Foreground(colorCyan)
hintDescStyle = lipgloss.NewStyle().Foreground(colorDim)
)
// Status bar
var (
statusBarStyle = lipgloss.NewStyle().Foreground(colorSubtle).Padding(0, 1)
statusHostStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
)
// Form
var (
formLabelStyle = lipgloss.NewStyle().Foreground(colorSubtle).Width(18)
formErrorStyle = lipgloss.NewStyle().Foreground(colorRed)
formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite).MarginBottom(1)
)
// Divider line
var dividerStyle = lipgloss.NewStyle().Foreground(colorBorder)
// renderReachable renders the reachable field with appropriate color.
func renderReachable(r *bool) string {
if r == nil {
return statusUnknown.Render("unknown")
}
if *r {
return statusOnline.Render("online")
}
return statusFailed.Render("failed")
}
// renderDrift renders a drift state with appropriate color.
func renderDrift(state string) string {
switch state {
case "clean":
return statusClean.Render("clean")
case "drift":
return statusDrift.Render("drift")
case "failed", "unreachable":
return statusUnreachable.Render(state)
default:
return statusUnknown.Render("unknown")
}
}
// renderRunStatus renders a run status with appropriate color.
func renderRunStatus(status string) string {
switch status {
case "clean", "ok":
return runCleanStyle.Render(status)
case "drift":
return runChangedStyle.Render("changed")
case "failed":
return runFailedStyle.Render("failed")
case "unreachable":
return runUnreachStyle.Render("unreachable")
default:
return subtleStyle.Render(status)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
"ansibletui/internal/ui"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
inv, err := inventory.Load(cfg.InventoryPath)
if err != nil {
fmt.Fprintf(os.Stderr, "inventory: %v\n", err)
os.Exit(1)
}
appDir, err := config.AppDir()
if err != nil {
fmt.Fprintf(os.Stderr, "app dir: %v\n", err)
os.Exit(1)
}
hist := history.New(filepath.Join(appDir, "runs"))
app := ui.New(cfg, inv, hist)
p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseAllMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
+18
View File
@@ -0,0 +1,18 @@
# UX References
Figma source: https://www.figma.com/design/30Zxic9K2sQ5kds66Rs9mt
## Exported Images
- `home-server-list-run-history.png` - direct Figma export of `01 Home - Server List + Run History`.
- `generated-concept-board.png` - original generated concept board with the three major UI directions.
## Figma Frames
- `01 Home - Server List + Run History` - node `3:14`
- `02 Check Apply Flow - Guided Command Preview` - node `3:158`
- `03 Run Details - Live Output + Recap` - node `3:244`
## Note
The Figma MCP Starter-plan call limit was reached while exporting the second and third frames. The Figma file contains all three editable frames; only the local PNG exports for the wizard and run-details frames need to be refreshed when the limit resets.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB