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:
+24
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user