refactor to break up large files

This commit is contained in:
2026-05-27 17:47:58 -04:00
parent 827dc7a3bc
commit cefd8a13ec
43 changed files with 5047 additions and 3185 deletions
+9 -5
View File
@@ -61,15 +61,11 @@ func BuildPlaybookArgsWithTags(inventoryPath, playbookRel, limit string, tags []
args = append(args, "--check")
case ModeCheckDiff:
args = append(args, "--check", "--diff")
case ModeApply:
}
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
@@ -169,3 +165,11 @@ func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
// JSONLEnv returns the environment variables required to enable JSONL output.
func JSONLEnv() map[string]string {
return map[string]string{
"ANSIBLE_STDOUT_CALLBACK": "ansible.posix.jsonl",
"ANSIBLE_JSON_INDENT": "0",
}
}
+18 -13
View File
@@ -22,24 +22,36 @@ func TestParseRecapClean(t *testing.T) {
func TestParseRecapDrift(t *testing.T) {
out := recap("web01", 4, 2, 0, 0, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Changed != 2 {
t.Fatalf("unexpected: %+v", got)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.OK != 4 || r.Changed != 2 || r.Unreachable != 0 || r.Failed != 0 {
t.Fatalf("unexpected recap: %+v", r)
}
}
func TestParseRecapFailed(t *testing.T) {
out := recap("web01", 3, 0, 0, 1, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Failed != 1 {
t.Fatalf("unexpected: %+v", got)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.OK != 3 || r.Changed != 0 || r.Unreachable != 0 || r.Failed != 1 {
t.Fatalf("unexpected recap: %+v", r)
}
}
func TestParseRecapUnreachable(t *testing.T) {
out := recap("web01", 0, 0, 1, 0, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Unreachable != 1 || got[0].Failed != 0 {
t.Fatalf("unexpected: %+v", got)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.OK != 0 || r.Changed != 0 || r.Unreachable != 1 || r.Failed != 0 {
t.Fatalf("unexpected recap: %+v", r)
}
}
@@ -264,10 +276,3 @@ func index(slice []string, s string) int {
}
return -1
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+84
View File
@@ -0,0 +1,84 @@
package ansible
import "ansibletui/internal/inventory"
// ApplyFindingsToHosts updates each Host's LastError field based on findings.
// Hosts with a diagnostic finding get their LastError set; hosts that appeared
// in the run without a failure have their LastError cleared when runStatus is a
// non-failure status. Pass an empty runStatus to suppress clearing (e.g. when
// the run record is unavailable).
func ApplyFindingsToHosts(hosts []*inventory.Host, findings []Finding, runStatus string) {
if len(findings) == 0 {
return
}
seenHosts := map[string]bool{}
for _, f := range findings {
if f.Host != "" {
seenHosts[f.Host] = true
}
}
for _, h := range hosts {
if !seenHosts[h.Name] {
continue
}
hostFindings := FindingsForHost(findings, h.Name)
if diag := FirstDiagnostic(hostFindings); diag != "" {
h.LastError = diag
} else if h.LastError != "" && runStatus != "" && runStatus != "failed" && runStatus != "unreachable" {
h.LastError = ""
}
}
}
// FindingsForHost returns the subset of findings matching the given host.
func FindingsForHost(findings []Finding, host string) []Finding {
var out []Finding
for _, f := range findings {
if f.Host == host {
out = append(out, f)
}
}
return out
}
// FlattenHostFindings collapses a per-host map into a flat slice.
func FlattenHostFindings(byHost map[string][]Finding) []Finding {
var out []Finding
for _, findings := range byHost {
out = append(out, findings...)
}
return out
}
// FirstDiagnostic returns the first actionable diagnostic string from findings.
// Prefers an explicit Diagnostic field; falls back to the Summary of the first
// failed or unreachable finding.
func FirstDiagnostic(findings []Finding) string {
for _, f := range findings {
if f.Diagnostic != "" {
return f.Diagnostic
}
}
for _, f := range findings {
if f.Status == "failed" || f.Status == "unreachable" {
return f.Summary
}
}
return ""
}
// CountByStatus returns the number of findings with the given status value.
func CountByStatus(findings []Finding, status string) int {
n := 0
for _, f := range findings {
if f.Status == status {
n++
}
}
return n
}
// CountDrift returns the number of findings with status "changed".
func CountDrift(findings []Finding) int {
return CountByStatus(findings, "changed")
}
+151
View File
@@ -0,0 +1,151 @@
package ansible_test
import (
"testing"
"ansibletui/internal/ansible"
"ansibletui/internal/inventory"
)
func makeFindings(specs []struct{ host, status, diag, summary string }) []ansible.Finding {
out := make([]ansible.Finding, len(specs))
for i, s := range specs {
out[i] = ansible.Finding{
Host: s.host,
Status: s.status,
Diagnostic: s.diag,
Summary: s.summary,
}
}
return out
}
func TestFindingsForHost(t *testing.T) {
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", "task changed"},
{"db01", "changed", "", "db changed"},
{"web01", "failed", "", "task failed"},
})
got := ansible.FindingsForHost(findings, "web01")
if len(got) != 2 {
t.Fatalf("got %d findings, want 2", len(got))
}
for _, f := range got {
if f.Host != "web01" {
t.Errorf("unexpected host %q in result", f.Host)
}
}
}
func TestFindingsForHostEmpty(t *testing.T) {
got := ansible.FindingsForHost(nil, "web01")
if len(got) != 0 {
t.Fatalf("expected empty, got %d", len(got))
}
}
func TestFirstDiagnosticWithDiagnostic(t *testing.T) {
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "failed", "connection refused", "task failed"},
})
got := ansible.FirstDiagnostic(findings)
if got != "connection refused" {
t.Errorf("got %q, want %q", got, "connection refused")
}
}
func TestFirstDiagnosticFallbackToStatus(t *testing.T) {
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", "config changed"},
{"web01", "failed", "", "deploy failed"},
})
got := ansible.FirstDiagnostic(findings)
if got != "deploy failed" {
t.Errorf("got %q, want %q", got, "deploy failed")
}
}
func TestFirstDiagnosticEmpty(t *testing.T) {
got := ansible.FirstDiagnostic(nil)
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestCountByStatus(t *testing.T) {
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", ""},
{"web02", "changed", "", ""},
{"db01", "failed", "", ""},
})
if got := ansible.CountByStatus(findings, "changed"); got != 2 {
t.Errorf("CountByStatus changed: got %d, want 2", got)
}
if got := ansible.CountByStatus(findings, "failed"); got != 1 {
t.Errorf("CountByStatus failed: got %d, want 1", got)
}
}
func TestCountDrift(t *testing.T) {
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", ""},
{"db01", "failed", "", ""},
})
if got := ansible.CountDrift(findings); got != 1 {
t.Errorf("CountDrift: got %d, want 1", got)
}
}
// ---- ApplyFindingsToHosts ----
func TestApplyFindingsToHostsSetsLastError(t *testing.T) {
h := &inventory.Host{Name: "web01"}
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "failed", "connection refused", ""},
})
ansible.ApplyFindingsToHosts([]*inventory.Host{h}, findings, "failed")
if h.LastError != "connection refused" {
t.Errorf("LastError = %q, want 'connection refused'", h.LastError)
}
}
func TestApplyFindingsToHostsClearsLastErrorOnSuccess(t *testing.T) {
h := &inventory.Host{Name: "web01", LastError: "old error"}
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", "config drift"},
})
ansible.ApplyFindingsToHosts([]*inventory.Host{h}, findings, "ok")
if h.LastError != "" {
t.Errorf("LastError = %q, want empty (cleared)", h.LastError)
}
}
func TestApplyFindingsToHostsDoesNotClearOnFailedRun(t *testing.T) {
h := &inventory.Host{Name: "web01", LastError: "previous error"}
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"web01", "changed", "", ""},
})
ansible.ApplyFindingsToHosts([]*inventory.Host{h}, findings, "failed")
if h.LastError != "previous error" {
t.Errorf("LastError = %q, should not be cleared for failed run", h.LastError)
}
}
func TestApplyFindingsToHostsNoopWhenEmpty(t *testing.T) {
h := &inventory.Host{Name: "web01", LastError: "existing"}
ansible.ApplyFindingsToHosts([]*inventory.Host{h}, nil, "ok")
if h.LastError != "existing" {
t.Errorf("LastError changed when findings are empty")
}
}
func TestApplyFindingsToHostsSkipsUnrelatedHosts(t *testing.T) {
h := &inventory.Host{Name: "web01", LastError: "should stay"}
findings := makeFindings([]struct{ host, status, diag, summary string }{
{"db01", "failed", "db error", ""},
})
ansible.ApplyFindingsToHosts([]*inventory.Host{h}, findings, "ok")
if h.LastError != "should stay" {
t.Errorf("LastError changed for unrelated host")
}
}
+18 -10
View File
@@ -129,7 +129,7 @@ type diffEntry struct {
// callers can safely pass mixed logs or fall back to ParseRecap.
func ParseJSONL(output []byte, playbook string, tags []string) JSONLResult {
var result JSONLResult
scanner := bufio.NewScanner(strings.NewReader(string(output)))
scanner := bufio.NewScanner(bytes.NewReader(output))
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -362,15 +362,6 @@ func logDiffs(in []diffEntry) []LogDiff {
return out
}
// CompactJSONLLine returns a concise display line for one JSONL event.
func CompactJSONLLine(line, playbook string, tags []string) (string, bool) {
result := ParseJSONLLine(line, playbook, tags)
if len(result.Lines) == 0 {
return "", false
}
return strings.Join(result.Lines, "\n"), true
}
// CompactLog returns the persisted log representation for an Ansible run at
// the requested app log level. debug and trace preserve the raw stream.
func CompactLog(output []byte, playbook string, tags []string, level string, exitCode int) []byte {
@@ -459,6 +450,9 @@ func compactJSONLLogLine(line, playbook string, tags []string, level compactLogL
func compactRunnerLogLines(event jsonlEvent, line, playbook string, tags []string, level compactLogLevel) []string {
status := eventStatus(event.Event)
result := parseRunnerEvent(event, line, playbook, tags)
if containsStructuredVMMarker(result.Rows) {
return []string{line}
}
if status == "ok" && len(result.Findings) == 0 {
return nil
}
@@ -480,6 +474,20 @@ func compactRunnerLogLines(event jsonlEvent, line, playbook string, tags []strin
return lines
}
func containsStructuredVMMarker(rows []LogRow) bool {
for _, row := range rows {
if containsStructuredVMMarkerText(row.Msg) || containsStructuredVMMarkerText(row.Summary) {
return true
}
}
return false
}
func containsStructuredVMMarkerText(text string) bool {
return strings.Contains(text, "ATUI_CHECK_RESULT: ") ||
strings.Contains(text, "ATUI_PACKAGE_PROGRESS: ")
}
func compactStatsLogLines(event jsonlEvent, level compactLogLevel) []string {
if len(event.Stats) == 0 {
return nil
+44
View File
@@ -1,6 +1,7 @@
package ansible
import (
"encoding/json"
"strings"
"testing"
)
@@ -65,6 +66,49 @@ func TestCompactLogWarnAndErrorKeepProblemsOnly(t *testing.T) {
}
}
func TestCompactLogKeepsStructuredUpdateCheckResult(t *testing.T) {
raw := strings.Join([]string{
`{"_event":"v2_runner_on_ok","hosts":{"node-1":{"changed":false,"msg":"ATUI_CHECK_RESULT: {\"updates\":4,\"docker\":true,\"reboot\":false,\"os\":\"Debian\",\"distro\":\"Ubuntu\"}"}},"task":{"name":"Report update status","path":"check_updates.yml:65"}}`,
`{"_event":"v2_playbook_on_stats","stats":{"node-1":{"ok":6,"changed":0,"failures":0,"unreachable":0,"skipped":2}}}`,
}, "\n")
got := string(CompactLog([]byte(raw), "check_updates.yml", nil, "info", 0))
if !strings.Contains(got, "ATUI_CHECK_RESULT") {
t.Fatalf("compact log dropped structured update result:\n%s", got)
}
// The first line of the compact output must be parseable JSONL with the
// ATUI_CHECK_RESULT payload accessible via the msg field.
firstLine := strings.SplitN(strings.TrimSpace(got), "\n", 2)[0]
var envelope struct {
Hosts map[string]struct {
Msg string `json:"msg"`
} `json:"hosts"`
}
if err := json.Unmarshal([]byte(firstLine), &envelope); err != nil {
t.Fatalf("first output line is not valid JSON: %v\nline: %s", err, firstLine)
}
host, ok := envelope.Hosts["node-1"]
if !ok || !strings.Contains(host.Msg, "ATUI_CHECK_RESULT") {
t.Fatalf("ATUI_CHECK_RESULT not in hosts[node-1].msg: %+v", envelope)
}
}
func TestCompactLogKeepsStructuredPackageProgress(t *testing.T) {
raw := strings.Join([]string{
`{"_event":"v2_runner_on_ok","hosts":{"node-1":{"changed":false,"msg":"ATUI_PACKAGE_PROGRESS: {\"package\":\"kernel-core.x86_64\",\"index\":2,\"total\":7,\"phase\":\"installing\"}"}},"task":{"name":"Report package update progress","path":"tasks/update_package_item.yml:4"}}`,
`{"_event":"v2_playbook_on_stats","stats":{"node-1":{"ok":6,"changed":1,"failures":0,"unreachable":0,"skipped":2}}}`,
}, "\n")
got := string(CompactLog([]byte(raw), "update_packages.yml", nil, "info", 0))
if !strings.Contains(got, "ATUI_PACKAGE_PROGRESS") {
t.Fatalf("compact log dropped structured package progress:\n%s", got)
}
if !strings.HasPrefix(strings.TrimSpace(got), "{") {
t.Fatalf("structured package progress should remain parseable JSONL:\n%s", got)
}
}
func TestCompactLogDebugAndTraceKeepRawLog(t *testing.T) {
raw := []byte("ordinary progress line\n{\"_event\":\"v2_runner_on_ok\",\"hosts\":{\"web01\":{\"changed\":false,\"ansible_facts\":{\"big\":\"value\"}}}}\n")
for _, level := range []string{"debug", "trace"} {
+36 -8
View File
@@ -210,14 +210,6 @@ func jobTargetsHost(inv *inventory.Inventory, job Job, hostName string) bool {
func driftFixTags(base Job, f ansible.Finding) []string {
taskTags := cleanTags(f.TaskTags)
if len(taskTags) == 0 && len(f.RunTags) == 0 {
// Backward compatibility for findings created before RunTags/TaskTags
// existed, where Tags represented task tags when no run tags were set.
if tags := cleanTags(f.Tags); len(tags) > 0 {
return tags
}
return cleanTags(base.Tags)
}
if len(taskTags) == 0 {
return cleanTags(base.Tags)
}
@@ -348,3 +340,39 @@ func ProgressBar(done, total, width int) string {
func (s Summary) RecapLine() string {
return fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d", s.OK, s.Changed, s.Failed, s.Unreachable, s.Skipped)
}
// BatchKey returns the concurrency group key for a compliance job. Jobs sharing a key
// run serially; jobs with different keys may run concurrently. Apply mode always groups
// by Group so per-host jobs within a group are serialized. Check/diff mode gives
// universal jobs a unique index-based key so they each run concurrently.
func BatchKey(job Job, mode ansible.Mode, index int) string {
if mode != ansible.ModeApply && job.Group == "" {
return fmt.Sprintf("universal:%d", index)
}
return job.Group
}
// RunTarget returns the display label for a compliance run. Returns "fleet" when
// multiple different targets are involved or when no jobs are provided.
func RunTarget(jobs []Job) string {
if len(jobs) == 0 {
return "fleet"
}
target := jobs[0].Limit
if target == "" {
target = jobs[0].Label()
}
for _, job := range jobs[1:] {
next := job.Limit
if next == "" {
next = job.Label()
}
if next != target {
return "fleet"
}
}
if target == "" || target == "all" {
return "fleet"
}
return target
}
+60 -2
View File
@@ -20,8 +20,8 @@ func TestPlanUniversalAndGroups(t *testing.T) {
cfg := config.Compliance{
Universal: []config.CompliancePlaybook{{Playbook: "site.yml"}, {Playbook: "site.yml"}},
Groups: map[string][]config.CompliancePlaybook{
"web": []config.CompliancePlaybook{{Playbook: "services/nginx.yml", Tags: []string{"nginx"}}},
"missing": []config.CompliancePlaybook{{Playbook: "ignored.yml"}},
"web": {{Playbook: "services/nginx.yml", Tags: []string{"nginx"}}},
"missing": {{Playbook: "ignored.yml"}},
},
}
@@ -323,3 +323,61 @@ func TestSummarizeRecapsApplyModeChangedIsOK(t *testing.T) {
t.Errorf("apply mode with changes: want 'ok', got %q", got.Status)
}
}
// ---- BatchKey ----
func TestBatchKeyUniversalCheckGetUniqueKeys(t *testing.T) {
j0 := Job{Playbook: "site.yml", Limit: "all"}
j1 := Job{Playbook: "tools.yml", Limit: "all"}
k0 := BatchKey(j0, ansible.ModeCheckDiff, 0)
k1 := BatchKey(j1, ansible.ModeCheckDiff, 1)
if k0 == k1 {
t.Fatalf("universal check jobs should get distinct keys, got %q", k0)
}
}
func TestBatchKeyGroupedJobUsesGroupName(t *testing.T) {
j := Job{Playbook: "dns.yml", Limit: "dns", Group: "dns"}
if got := BatchKey(j, ansible.ModeCheckDiff, 2); got != "dns" {
t.Fatalf("grouped job key = %q, want dns", got)
}
}
func TestBatchKeyApplyModeUniversalUsesEmptyGroup(t *testing.T) {
j := Job{Playbook: "site.yml", Limit: "all"}
if got := BatchKey(j, ansible.ModeApply, 0); got != "" {
t.Fatalf("universal apply key = %q, want empty string", got)
}
}
// ---- RunTarget ----
func TestRunTargetEmpty(t *testing.T) {
if got := RunTarget(nil); got != "fleet" {
t.Fatalf("empty jobs: got %q, want fleet", got)
}
}
func TestRunTargetSingleHost(t *testing.T) {
jobs := []Job{{Playbook: "site.yml", Limit: "web01"}}
if got := RunTarget(jobs); got != "web01" {
t.Fatalf("single host: got %q, want web01", got)
}
}
func TestRunTargetMultipleDifferentTargets(t *testing.T) {
jobs := []Job{
{Playbook: "site.yml", Limit: "web01"},
{Playbook: "dns.yml", Limit: "dns"},
}
if got := RunTarget(jobs); got != "fleet" {
t.Fatalf("mixed targets: got %q, want fleet", got)
}
}
func TestRunTargetAllIsFleet(t *testing.T) {
jobs := []Job{{Playbook: "site.yml", Limit: "all"}}
if got := RunTarget(jobs); got != "fleet" {
t.Fatalf("'all' limit: got %q, want fleet", got)
}
}
+66
View File
@@ -0,0 +1,66 @@
package compliance
import (
"sort"
"ansibletui/internal/inventory"
)
// HostsForJob returns the inventory hosts targeted by a compliance job.
func HostsForJob(inv *inventory.Inventory, job Job) []*inventory.Host {
if job.Limit == "all" {
return inv.Hosts
}
var out []*inventory.Host
for _, h := range inv.Hosts {
if h.Name == job.Limit || h.Group == job.Limit {
out = append(out, h)
}
}
return out
}
// JobTargetsHost reports whether a compliance job targets the given host.
func JobTargetsHost(job Job, host *inventory.Host) bool {
switch job.Limit {
case "", "all", host.Name:
return true
case host.Group:
return host.Group != ""
default:
return false
}
}
// ExpandGroupJobsPerHost replaces each group-level job with one job per host in
// that group. Universal jobs (Group == "") are passed through unchanged.
// Used for apply runs so changes roll out serially within a group.
func ExpandGroupJobsPerHost(jobs []Job, inv *inventory.Inventory) []Job {
expanded := make([]Job, 0, len(jobs))
for _, job := range jobs {
if job.Group == "" || job.Limit != job.Group {
expanded = append(expanded, job)
continue
}
var hosts []string
for _, h := range inv.Hosts {
if h.Group == job.Group {
hosts = append(hosts, h.Name)
}
}
sort.Strings(hosts)
if len(hosts) == 0 {
expanded = append(expanded, job) // no hosts known, keep as-is
continue
}
for _, name := range hosts {
expanded = append(expanded, Job{
Playbook: job.Playbook,
Tags: job.Tags,
Limit: name,
Group: job.Group,
})
}
}
return expanded
}
+107
View File
@@ -0,0 +1,107 @@
package compliance_test
import (
"testing"
"ansibletui/internal/compliance"
"ansibletui/internal/inventory"
)
func makeInventory(specs [][2]string) *inventory.Inventory {
hosts := make([]*inventory.Host, len(specs))
for i, s := range specs {
hosts[i] = &inventory.Host{Name: s[0], Group: s[1]}
}
return &inventory.Inventory{Hosts: hosts}
}
// ---- ExpandGroupJobsPerHost ----
func TestExpandGroupJobsPerHostGroupJob(t *testing.T) {
inv := makeInventory([][2]string{{"web01", "web"}, {"web02", "web"}})
jobs := []compliance.Job{
{Playbook: "hardening.yml", Limit: "web", Group: "web"},
}
expanded := compliance.ExpandGroupJobsPerHost(jobs, inv)
if len(expanded) != 2 {
t.Fatalf("expected 2 jobs, got %d", len(expanded))
}
names := map[string]bool{}
for _, j := range expanded {
names[j.Limit] = true
}
if !names["web01"] || !names["web02"] {
t.Errorf("expanded jobs missing expected hosts: %v", expanded)
}
}
func TestExpandGroupJobsPerHostUniversal(t *testing.T) {
inv := makeInventory([][2]string{{"web01", "web"}})
jobs := []compliance.Job{
{Playbook: "universal.yml", Limit: "all", Group: ""},
}
expanded := compliance.ExpandGroupJobsPerHost(jobs, inv)
if len(expanded) != 1 {
t.Fatalf("universal job should pass through unchanged, got %d", len(expanded))
}
if expanded[0].Limit != "all" {
t.Errorf("Limit changed: got %q, want %q", expanded[0].Limit, "all")
}
}
func TestExpandGroupJobsPerHostNoHosts(t *testing.T) {
inv := makeInventory([][2]string{{"db01", "db"}})
jobs := []compliance.Job{
{Playbook: "hardening.yml", Limit: "web", Group: "web"},
}
expanded := compliance.ExpandGroupJobsPerHost(jobs, inv)
// no hosts in "web" group — job passes through unchanged
if len(expanded) != 1 {
t.Fatalf("expected 1 (unchanged) job, got %d", len(expanded))
}
if expanded[0].Limit != "web" {
t.Errorf("Limit changed: got %q, want %q", expanded[0].Limit, "web")
}
}
// ---- JobTargetsHost ----
func TestJobTargetsHostByName(t *testing.T) {
host := &inventory.Host{Name: "web01", Group: "web"}
job := compliance.Job{Limit: "web01"}
if !compliance.JobTargetsHost(job, host) {
t.Error("expected true when Limit == host.Name")
}
}
func TestJobTargetsHostByGroup(t *testing.T) {
host := &inventory.Host{Name: "web01", Group: "web"}
job := compliance.Job{Limit: "web"}
if !compliance.JobTargetsHost(job, host) {
t.Error("expected true when Limit == host.Group")
}
}
func TestJobTargetsHostAll(t *testing.T) {
host := &inventory.Host{Name: "web01", Group: "web"}
job := compliance.Job{Limit: "all"}
if !compliance.JobTargetsHost(job, host) {
t.Error("expected true for Limit=all")
}
}
func TestJobTargetsHostEmpty(t *testing.T) {
host := &inventory.Host{Name: "web01", Group: "web"}
job := compliance.Job{Limit: ""}
if !compliance.JobTargetsHost(job, host) {
t.Error("expected true for empty Limit")
}
}
func TestJobTargetsHostNoMatch(t *testing.T) {
host := &inventory.Host{Name: "web01", Group: "web"}
job := compliance.Job{Limit: "other"}
if compliance.JobTargetsHost(job, host) {
t.Error("expected false for non-matching Limit")
}
}
+147
View File
@@ -0,0 +1,147 @@
package git
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// initRepo creates a bare git repository in dir and returns its path.
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if _, err := Run(context.Background(), dir, "init"); err != nil {
t.Fatalf("git init: %v", err)
}
if _, err := Run(context.Background(), dir, "config", "user.email", "test@test.com"); err != nil {
t.Fatalf("git config email: %v", err)
}
if _, err := Run(context.Background(), dir, "config", "user.name", "Test"); err != nil {
t.Fatalf("git config name: %v", err)
}
return dir
}
// commitFile stages and commits a file in dir.
func commitFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
if _, err := Run(context.Background(), dir, "add", name); err != nil {
t.Fatalf("git add: %v", err)
}
if _, err := Run(context.Background(), dir, "commit", "-m", "add "+name); err != nil {
t.Fatalf("git commit: %v", err)
}
}
// ---- IsRepo ----
func TestIsRepoReturnsTrueForGitRepo(t *testing.T) {
dir := initRepo(t)
if !IsRepo(dir) {
t.Errorf("IsRepo(%q) = false, want true for a git repo", dir)
}
}
func TestIsRepoReturnsFalseForPlainDir(t *testing.T) {
dir := t.TempDir()
if IsRepo(dir) {
t.Errorf("IsRepo(%q) = true, want false for a plain directory", dir)
}
}
func TestIsRepoReturnsFalseForMissingDir(t *testing.T) {
if IsRepo("/nonexistent/path/that/does/not/exist") {
t.Error("IsRepo returned true for nonexistent path")
}
}
// ---- Run ----
func TestRunReturnsCommandOutput(t *testing.T) {
dir := initRepo(t)
commitFile(t, dir, "readme.txt", "hello\n")
out, err := Run(context.Background(), dir, "log", "--oneline")
if err != nil {
t.Fatalf("git log: %v", err)
}
if out == "" {
t.Error("git log returned empty output")
}
}
func TestRunReturnsErrorForUnknownCommand(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), dir, "not-a-real-subcommand")
if err == nil {
t.Error("expected error for unknown git subcommand, got nil")
}
}
func TestRunRespectsContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
dir := initRepo(t)
_, err := Run(ctx, dir, "log")
if err == nil {
t.Error("expected error running git with cancelled context, got nil")
}
}
// ---- ShortStatus ----
func TestShortStatusReturnsNotAGitRepoForPlainDir(t *testing.T) {
dir := t.TempDir()
got := ShortStatus(context.Background(), dir)
if got != "not a git repo" {
t.Errorf("ShortStatus(plain dir) = %q, want 'not a git repo'", got)
}
}
func TestShortStatusReturnsCleanForCommittedRepo(t *testing.T) {
dir := initRepo(t)
commitFile(t, dir, "readme.txt", "hello\n")
got := ShortStatus(context.Background(), dir)
if got == "" {
t.Fatal("ShortStatus returned empty string")
}
// Should end with "(clean)" because there are no uncommitted changes.
if !strings.HasSuffix(got, "(clean)") {
t.Errorf("ShortStatus = %q, want to end with '(clean)'", got)
}
}
func TestShortStatusReturnsDirtyAfterUncommittedChange(t *testing.T) {
dir := initRepo(t)
commitFile(t, dir, "readme.txt", "hello\n")
// Modify the file without staging.
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("changed\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got := ShortStatus(context.Background(), dir)
if got == "" {
t.Fatal("ShortStatus returned empty string")
}
if !strings.HasSuffix(got, "(dirty)") {
t.Errorf("ShortStatus = %q, want to end with '(dirty)'", got)
}
}
// ---- StatusLine ----
func TestStatusLineIncludesRepoName(t *testing.T) {
dir := t.TempDir()
got := StatusLine(context.Background(), "myrepo", dir)
if got[:7] != "myrepo:" {
t.Errorf("StatusLine = %q, want to start with 'myrepo:'", got)
}
}
+51
View File
@@ -0,0 +1,51 @@
package history
import (
"time"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
)
// RunParams holds the metadata needed to build a RunRecord after a playbook finishes.
type RunParams struct {
Playbook string
Host string
Mode string // ansible.Mode.String()
Tags []string
StartTime time.Time
EndTime time.Time
ExitCode int
}
// BuildRecord constructs a RunRecord from post-run data. It summarises recaps and
// findings into a single status. If recaps is empty and err is non-nil, status is
// "failed". If recaps is empty and err is nil, status is "ok".
func BuildRecord(p RunParams, recaps []ansible.Recap, findings []ansible.Finding, mode ansible.Mode, err error) *RunRecord {
rec := &RunRecord{
Playbook: p.Playbook,
Host: p.Host,
Mode: p.Mode,
Tags: p.Tags,
StartTime: p.StartTime,
EndTime: p.EndTime,
ExitCode: p.ExitCode,
}
if len(recaps) > 0 {
summary := compliance.SummarizeRecaps(recaps, mode)
rec.OK = summary.OK
rec.Changed = summary.Changed
rec.Failed = summary.Failed
rec.Unreachable = summary.Unreachable
rec.Skipped = summary.Skipped
rec.Status = summary.Status
rec.DriftCount = ansible.CountByStatus(findings, "changed")
} else if err != nil {
rec.Status = "failed"
} else if mode.IsCheckMode() {
rec.Status = "clean"
} else {
rec.Status = "ok"
}
return rec
}
+97
View File
@@ -0,0 +1,97 @@
package history
import (
"errors"
"testing"
"time"
"ansibletui/internal/ansible"
)
var testTime = time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
func TestBuildRecordWithRecaps(t *testing.T) {
recaps := []ansible.Recap{
{Host: "web01", OK: 3, Changed: 1, Failed: 0},
}
findings := []ansible.Finding{
{Host: "web01", Status: "changed"},
}
p := RunParams{
Playbook: "site.yml",
Host: "web01",
Mode: "check+diff",
StartTime: testTime,
EndTime: testTime.Add(5 * time.Second),
}
rec := BuildRecord(p, recaps, findings, ansible.ModeCheckDiff, nil)
if rec.Playbook != "site.yml" {
t.Errorf("Playbook = %q, want site.yml", rec.Playbook)
}
if rec.Status != "drift" {
t.Errorf("Status = %q, want drift", rec.Status)
}
if rec.DriftCount != 1 {
t.Errorf("DriftCount = %d, want 1", rec.DriftCount)
}
if rec.Changed != 1 {
t.Errorf("Changed = %d, want 1", rec.Changed)
}
}
func TestBuildRecordNoRecapsWithError(t *testing.T) {
p := RunParams{Playbook: "site.yml", Host: "web01", Mode: "apply"}
rec := BuildRecord(p, nil, nil, ansible.ModeApply, errors.New("ssh: connection refused"))
if rec.Status != "failed" {
t.Errorf("Status = %q, want failed", rec.Status)
}
}
func TestBuildRecordNoRecapsNoError(t *testing.T) {
p := RunParams{Playbook: "site.yml", Host: "web01", Mode: "apply"}
rec := BuildRecord(p, nil, nil, ansible.ModeApply, nil)
if rec.Status != "ok" {
t.Errorf("Status = %q, want ok", rec.Status)
}
}
func TestBuildRecordNoRecapsCheckModeNoError(t *testing.T) {
p := RunParams{Playbook: "site.yml", Host: "web01", Mode: "check+diff"}
rec := BuildRecord(p, nil, nil, ansible.ModeCheckDiff, nil)
if rec.Status != "clean" {
t.Errorf("Status = %q, want clean (check mode, no recaps, no error)", rec.Status)
}
}
func TestBuildRecordApplyModeWithChangesIsOK(t *testing.T) {
recaps := []ansible.Recap{{Host: "web01", OK: 5, Changed: 2}}
p := RunParams{Playbook: "site.yml", Host: "web01", Mode: "apply"}
rec := BuildRecord(p, recaps, nil, ansible.ModeApply, nil)
if rec.Status != "ok" {
t.Errorf("Status = %q, want ok (apply mode with changes)", rec.Status)
}
}
func TestBuildRecordPreservesMetadata(t *testing.T) {
p := RunParams{
Playbook: "dns.yml",
Host: "dns01",
Mode: "check",
Tags: []string{"dns", "config"},
StartTime: testTime,
EndTime: testTime.Add(10 * time.Second),
ExitCode: 2,
}
rec := BuildRecord(p, nil, nil, ansible.ModeCheck, errors.New("exit 2"))
if rec.Host != "dns01" {
t.Errorf("Host = %q, want dns01", rec.Host)
}
if len(rec.Tags) != 2 || rec.Tags[0] != "dns" {
t.Errorf("Tags = %v, want [dns config]", rec.Tags)
}
if rec.ExitCode != 2 {
t.Errorf("ExitCode = %d, want 2", rec.ExitCode)
}
}
+22 -1
View File
@@ -1,6 +1,7 @@
package history
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
@@ -31,6 +32,13 @@ type RunRecord struct {
Tags []string `json:"tags,omitempty"`
DriftCount int `json:"drift_count,omitempty"`
ExitCode int `json:"exit_code"`
UpdateCount int `json:"update_count,omitempty"`
UpdateCheckedAt time.Time `json:"update_checked_at,omitempty"`
UpdateDocker bool `json:"update_docker,omitempty"`
UpdateReboot bool `json:"update_reboot,omitempty"`
UpdateOSFamily string `json:"update_os_family,omitempty"`
UpdateDistro string `json:"update_distro,omitempty"`
}
// TimeLabel returns HH:MM for display.
@@ -89,7 +97,7 @@ func (h *History) SaveWithFindings(rec *RunRecord, log []byte, findings []ansibl
return err
}
if rec.ID == "" {
rec.ID = time.Now().UTC().Format("20060102T150405")
rec.ID = newRunID()
}
logName := fmt.Sprintf("%s-%s-%s.log", rec.ID, sanitize(rec.Host), rec.Mode)
@@ -157,6 +165,9 @@ func (h *History) List(n int) ([]*RunRecord, error) {
if filepath.Ext(e.Name()) != ".json" {
continue
}
if strings.HasSuffix(e.Name(), "-findings.json") {
continue
}
data, err := os.ReadFile(filepath.Join(h.dir, e.Name()))
if err != nil {
continue
@@ -178,6 +189,16 @@ func (h *History) List(n int) ([]*RunRecord, error) {
return records, nil
}
func newRunID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return time.Now().UTC().Format("20060102T150405.000000000")
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
+3 -1
View File
@@ -205,7 +205,9 @@ func TestTimeLabel(t *testing.T) {
func TestLogFileNameContainsHostAndMode(t *testing.T) {
h := New(t.TempDir())
rec := makeRecord("ts123", "adguard-1", "check+diff", "drift", time.Now())
h.Save(rec, []byte("log"))
if err := h.Save(rec, []byte("log")); err != nil {
t.Fatal(err)
}
if !strings.Contains(rec.LogFile, "adguard-1") {
t.Errorf("log file name %q does not contain host", rec.LogFile)
-35
View File
@@ -45,41 +45,6 @@ func TestDiscover_excludesGroupVarsDir(t *testing.T) {
}
}
func TestDiscover_homelabPlaybooksLayout(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip(err)
}
dir := filepath.Join(home, ".ansibletui", "playbooks")
if _, err := os.Stat(dir); err != nil {
t.Skip("homelab playbooks dir not present")
}
got, err := Discover(dir)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"site.yml": true,
"setup.yml": true,
"passwordless.yml": true,
"compliance.yaml": true,
"services/pi-hole.yml": true,
"services/kubernetes.yml": true,
"security/selinux-permissive.yml": true,
"security/ipv6-disable.yml": true,
"playbooks/xcp-guest-tools.yml": true,
}
for _, p := range got {
if !want[p] {
t.Errorf("unexpected playbook in list: %q", p)
}
delete(want, p)
}
if len(want) > 0 {
t.Errorf("missing playbooks: %v", want)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+14 -9
View File
@@ -18,10 +18,9 @@ func drain(ch <-chan string) []string {
func TestRunEcho(t *testing.T) {
ch := make(chan string, 16)
code, log, err := Run(context.Background(), []string{"echo", "hello"}, "", ch)
<-ch // channel is closed by Run; drain any remaining
drain(ch)
if err != nil {
// On non-zero exit, err is an *exec.ExitError — only fatal if code != 0.
t.Logf("Run error (may be normal): %v", err)
}
if code != 0 {
@@ -51,7 +50,7 @@ func TestRunStreamsLines(t *testing.T) {
close(done)
}()
Run(context.Background(), []string{"printf", "line1\nline2\nline3\n"}, "", ch)
_, _, _ = Run(context.Background(), []string{"printf", "line1\nline2\nline3\n"}, "", ch)
<-done
if len(received) != 3 {
@@ -66,22 +65,28 @@ func TestRunContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan string, 16)
started := make(chan struct{})
done := make(chan struct{})
go func() {
// 'sleep 30' will be killed when ctx is cancelled.
Run(ctx, []string{"sleep", "30"}, "", ch)
// 'cat /dev/stdin' blocks until stdin closes or context is cancelled.
// We pipe /dev/null as stdin so it blocks indefinitely without sleeping.
_, _, _ = Run(ctx, []string{"cat", "/dev/stdin"}, "", ch)
for range ch {
}
close(done)
}()
// Give sleep a moment to start, then cancel.
time.Sleep(100 * time.Millisecond)
cancel()
// Cancel immediately — the process start-up race is handled by the 3s timeout.
go func() {
close(started)
cancel()
}()
<-started
select {
case <-done:
// Good — Run returned promptly after cancel.
// Good — Run returned after context cancel.
case <-time.After(3 * time.Second):
t.Fatal("Run did not return within 3s after context cancel")
}
+5 -1030
View File
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
package ui
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/history"
"ansibletui/internal/runner"
)
// complianceJobResult records the outcome of one job in a compliance scan.
type complianceJobResult struct {
label string // e.g. "all", "dns", "appliance"
playbook string
status string // clean, drift, failed, unreachable
changed int
failed int
}
// 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 {
if a.cancelRun != nil {
a.cancelRun()
}
ch := make(chan tea.Msg, 256)
ctx, cancel := context.WithCancel(context.Background())
a.cancelRun = cancel
a.runCh = ch
a.runStarted = time.Now()
a.showRunLogs = false
a.logTable.Reset()
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.RunWithEnv(ctx, argv, a.cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, playbook, nil).Rows; len(rows) > 0 {
ch <- outputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- outputLineMsg{line: line}
}
}
r := <-resCh
end := time.Now()
parsed := ansible.ParseJSONL(r.log, playbook, nil)
recaps := parsed.Recaps
findings := parsed.Findings
if len(recaps) == 0 {
recaps = ansible.ParseRecap(string(r.log))
}
if len(recaps) > 0 {
compliance.ApplyRecaps(inv, recaps, mode, end)
}
rec := history.BuildRecord(history.RunParams{
Playbook: playbook,
Host: host,
Mode: mode.String(),
StartTime: start,
EndTime: end,
ExitCode: r.code,
}, recaps, findings, mode, r.err)
savedLog := ansible.CompactLog(r.log, playbook, nil, a.cfg.LogLevel, r.code)
_ = hist.SaveWithFindings(rec, savedLog, findings)
ch <- runDoneMsg{record: rec, log: savedLog, findings: findings, err: r.err}
}()
return waitRun(ch)
}
func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showScreen bool, action string) tea.Cmd {
if a.cancelRun != nil {
a.cancelRun()
}
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.cancelRun = cancel
a.runCh = ch
a.runStarted = time.Now()
a.showRunLogs = false
a.outputLines = nil
a.outputVp.SetContent("")
a.logTable.Reset()
a.complianceMode = showScreen
a.complianceRunning = true
a.complianceAction = action
a.complianceJobs = jobs
a.complianceIndex = 0
a.complianceTotal = len(jobs)
a.complianceSummary = compliance.Summary{}
a.complianceJobResults = nil
a.flowMode = mode
if showScreen {
a.screen = ScreenCmdFlow
a.flowHost = compliance.RunTarget(jobs)
a.flowPlaybook = action
a.flowStep = StepExecuting
}
hist := a.hist
inv := a.inv
cfg := a.cfg
go func() {
defer close(ch)
start := time.Now()
// For apply runs, expand each group job into per-host jobs so changes
// roll out one server at a time per group rather than hitting all
// members simultaneously. Check/check+diff runs keep group-level limits.
if mode == ansible.ModeApply {
jobs = compliance.ExpandGroupJobsPerHost(jobs, inv)
}
// Group jobs by batch key. Jobs in the same group run serially (one
// host at a time); groups themselves run concurrently.
type groupBatch []compliance.Job
batchMap := map[string]groupBatch{}
var batchOrder []string
for i, job := range jobs {
g := compliance.BatchKey(job, mode, i)
if _, ok := batchMap[g]; !ok {
batchOrder = append(batchOrder, g)
}
batchMap[g] = append(batchMap[g], job)
}
var mu sync.Mutex
var combined bytes.Buffer
var allRecaps []ansible.Recap
var allFindings []ansible.Finding
var firstErr error
exitCode := 0
startedIdx := 0
var wg sync.WaitGroup
for _, g := range batchOrder {
batch := batchMap[g]
wg.Add(1)
go func(batch groupBatch) {
defer wg.Done()
for _, job := range batch {
select {
case <-ctx.Done():
return
default:
}
mu.Lock()
idx := startedIdx
startedIdx++
mu.Unlock()
ch <- complianceJobStartedMsg{job: job, index: idx, total: len(jobs)}
lineCh := make(chan string, 256)
argv := ansible.BuildPlaybookArgsWithTags(cfg.EffectiveInventoryPath(), job.Playbook, job.Limit, job.Tags, mode)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
jobStart := time.Now()
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, job.Playbook, job.Tags).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = job.Label()
}
if rows[i].Playbook == "" {
rows[i].Playbook = job.Playbook
}
}
ch <- outputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- outputLineMsg{line: "[" + job.Label() + "] " + line}
}
}
r := <-resCh
jobEnd := time.Now()
parsed := ansible.ParseJSONL(r.log, job.Playbook, job.Tags)
recaps := parsed.Recaps
if len(recaps) == 0 {
recaps = ansible.ParseRecap(string(r.log))
}
findings := parsed.Findings
jobSummary := compliance.SummarizeRecaps(recaps, mode)
if len(recaps) == 0 {
if r.err != nil {
jobSummary.Status = "failed"
} else {
jobSummary.Status = "clean"
}
}
jobRec := history.BuildRecord(history.RunParams{
Playbook: job.Playbook,
Host: job.Label(),
Mode: mode.String(),
Tags: job.Tags,
StartTime: jobStart,
EndTime: jobEnd,
ExitCode: r.code,
}, recaps, findings, mode, r.err)
savedLog := ansible.CompactLog(r.log, job.Playbook, job.Tags, cfg.LogLevel, r.code)
_ = hist.SaveWithFindings(jobRec, savedLog, findings)
ch <- complianceJobDoneMsg{
job: job,
index: idx,
total: len(jobs),
status: jobSummary.Status,
changed: jobSummary.Changed,
failed: jobSummary.Failed,
findings: findings,
}
mu.Lock()
if r.code != 0 && exitCode == 0 {
exitCode = r.code
}
if r.err != nil && firstErr == nil {
firstErr = r.err
}
combined.WriteString(fmt.Sprintf("=== %s [%s] ===\n", job.Playbook, job.Label()))
combined.Write(savedLog)
if len(savedLog) == 0 || savedLog[len(savedLog)-1] != '\n' {
combined.WriteByte('\n')
}
allRecaps = append(allRecaps, recaps...)
allFindings = append(allFindings, findings...)
mu.Unlock()
}
}(batch)
}
wg.Wait()
end := time.Now()
// Apply all recaps at once so per-host state is the worst-case across
// all playbooks rather than whichever job happened to finish last.
compliance.ApplyRecaps(inv, allRecaps, mode, end)
summary := compliance.SummarizeRecaps(allRecaps, mode)
if len(allRecaps) == 0 {
if firstErr != nil {
summary.Status = "failed"
} else {
summary.Status = "clean"
}
}
rec := &history.RunRecord{
Playbook: action,
Host: compliance.RunTarget(jobs),
Mode: mode.String(),
Status: summary.Status,
OK: summary.OK,
Changed: summary.Changed,
Failed: summary.Failed,
Unreachable: summary.Unreachable,
Skipped: summary.Skipped,
StartTime: start,
EndTime: end,
ExitCode: exitCode,
DriftCount: ansible.CountByStatus(allFindings, "changed"),
}
_ = hist.SaveWithFindings(rec, combined.Bytes(), allFindings)
ch <- complianceDoneMsg{record: rec, log: combined.Bytes(), findings: allFindings, summary: summary, action: action, err: firstErr}
}()
return waitRun(ch)
}
func (a *App) startStartupComplianceCheck() tea.Cmd {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
return nil
}
a.statusMsg = fmt.Sprintf("startup compliance check queued: %d mapped checks", len(jobs))
return a.startComplianceRun(jobs, ansible.ModeCheckDiff, false, "startup compliance check")
}
func (a *App) loadComplianceMapping() {
cfg, source, ok, err := compliance.LoadFromDir(a.cfg.EffectivePlaybookDir())
if err != nil {
a.errMsg = fmt.Sprintf("compliance.yaml: %v", err)
return
}
if ok {
a.cfg.Compliance = cfg
a.complianceSource = source
}
}
func (a *App) applyFindings(findings []ansible.Finding, rec *history.RunRecord) {
if len(findings) == 0 {
return
}
if a.hostFindings == nil {
a.hostFindings = map[string][]ansible.Finding{}
}
seenHosts := map[string]bool{}
for _, f := range findings {
if f.Host != "" {
seenHosts[f.Host] = true
}
}
for host := range seenHosts {
a.hostFindings[host] = ansible.FindingsForHost(findings, host)
}
var runStatus string
if rec != nil {
runStatus = rec.Status
}
ansible.ApplyFindingsToHosts(a.inv.Hosts, findings, runStatus)
}
+142
View File
@@ -0,0 +1,142 @@
package ui
import (
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/compliance"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
"ansibletui/internal/playbooks"
)
func loadRunsCmd(hist *history.History) tea.Cmd {
return func() tea.Msg {
runs, err := hist.List(maxRecentRuns)
return runsLoadedMsg{runs: runs, 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
}
}
func (a *App) filteredServers() []*inventory.Host {
if a.filterStr == "" {
out := append([]*inventory.Host(nil), a.inv.Hosts...)
sortHostsForDashboard(out)
return out
}
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)
}
}
sortHostsForDashboard(out)
return out
}
func sortHostsForDashboard(hosts []*inventory.Host) {
sort.SliceStable(hosts, func(i, j int) bool {
if hosts[i].Group != hosts[j].Group {
return hosts[i].Group < hosts[j].Group
}
return hosts[i].Name < hosts[j].Name
})
}
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) selectServerByName(name string) bool {
for i, host := range a.filteredServers() {
if host.Name == name {
a.serverCursor = i
return true
}
}
return false
}
func (a *App) selectedRunRecord() *history.RunRecord {
if len(a.runs) == 0 || a.runCursor >= len(a.runs) {
return nil
}
return a.runs[a.runCursor]
}
// sidebarWidth returns the current sidebar column width based on expand state.
func (a *App) sidebarWidth() int {
if a.sidebarExpanded {
return 20
}
return 8
}
func (a *App) loadPlaybooks() {
pbs, _ := playbooks.Discover(a.cfg.EffectivePlaybookDir())
a.playbookList = pbs
a.pbCursor = 0
for i, pb := range pbs {
if pb == a.cfg.RecentPlaybook {
a.pbCursor = i
break
}
}
}
func (a *App) selectedHostComplianceJobs(host *inventory.Host) []compliance.Job {
if host == nil {
return nil
}
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
selected := make([]compliance.Job, 0, len(jobs))
for _, job := range jobs {
if !compliance.JobTargetsHost(job, host) {
continue
}
job.Limit = host.Name
selected = append(selected, job)
}
return selected
}
// 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))
}
func padANSI(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
+128
View File
@@ -0,0 +1,128 @@
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
)
// 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:
_, cmd := a.logTable.Update(msg)
return a, cmd
case ScreenCmdFlow:
if a.flowStep == StepExecuting || a.flowStep == StepDone {
_, cmd := a.logTable.Update(msg)
return a, cmd
}
case ScreenVMUpdater:
return a.handleVMUpdaterMouse(msg)
default:
}
return a, nil
}
// homeLayout returns approximate screen coordinates for the home layout.
// These are estimates kept for wheel-scroll fallback; primary click detection
// uses zone.Get().InBounds() instead of coordinate math.
func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, rightX0, runsRowY0 int) {
const titleLines = 1
const panelPreamble = 5
serverRowY0 = titleLines + panelPreamble
sideW := a.sidebarWidth()
mainW := a.width - sideW - 1
serversW := mainW * 66 / 100
serverX0 = sideW + 2
serverX1 = sideW + serversW
rightX0 = serverX1 + 2
// Runs panel sits below the detail panel (detailH=12) in the right column.
// runs panel preamble: border(1) + "Recent Runs"(1) + MarginBottom-blank(1) = 3
const detailH = 12
const runsPreamble = 3
runsRowY0 = titleLines + detailH + runsPreamble
return
}
func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
if a.sidebarTab == TabVMUpdate {
return a.handleVMUpdaterMouse(msg)
}
a.moveCursorUp()
case tea.MouseButtonWheelDown:
if a.sidebarTab == TabVMUpdate {
return a.handleVMUpdaterMouse(msg)
}
a.moveCursorDown()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
break
}
// Hamburger toggle — always works regardless of active tab.
if zone.Get("hamburger").InBounds(msg) {
a.sidebarExpanded = !a.sidebarExpanded
return a, nil
}
// Sidebar tab clicks — always work regardless of active tab.
for i, id := range []string{"tab-0", "tab-1", "tab-2", "tab-3", "tab-4"} {
if zone.Get(id).InBounds(msg) {
if i == TabConfig && a.sidebarTab != TabConfig {
a.sidebarTab = TabConfig
a.syncActivePanelToTab()
return a, a.configTabEnterCmd()
}
if i == TabVMUpdate && a.sidebarTab != TabVMUpdate {
a.sidebarTab = TabVMUpdate
a.syncActivePanelToTab()
return a, a.openVMUpdaterScreen()
}
a.sidebarTab = i
a.syncActivePanelToTab()
return a, nil
}
}
// Content-area clicks are routed based on active tab.
if a.sidebarTab == TabVMUpdate {
return a.handleVMUpdaterMouse(msg)
}
// Server row clicks.
hosts := a.filteredServers()
for i := range hosts {
if zone.Get(fmt.Sprintf("server-%d", i)).InBounds(msg) {
a.serverCursor = i
a.activePanel = PanelServers
return a, nil
}
}
// Run row clicks — single click selects, double-click opens details.
for i := range a.runs {
if zone.Get(fmt.Sprintf("run-%d", i)).InBounds(msg) {
if a.runCursor == i && a.activePanel == PanelRuns {
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
return a, nil
}
a.runCursor = i
a.activePanel = PanelRuns
return a, nil
}
}
default:
}
return a, nil
}
+340
View File
@@ -0,0 +1,340 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/inventory"
"ansibletui/internal/vmupdate"
)
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:
if msg.err != nil {
a.errMsg = fmt.Sprintf("history: %v", msg.err)
}
a.runs = msg.runs
a.applyVMHistory(msg.runs)
if a.runCursor >= len(a.runs) {
if len(a.runs) > 0 {
a.runCursor = len(a.runs) - 1
} else {
a.runCursor = 0
}
}
return a, nil
case outputLineMsg:
if len(msg.rows) > 0 {
for _, row := range msg.rows {
a.logTable.AddRow(row)
}
} else {
a.outputLines = append(a.outputLines, msg.line)
a.outputVp.SetContent(strings.Join(a.outputLines, "\n"))
a.outputVp.GotoBottom()
a.logTable.AddPlain(msg.line)
}
return a, waitRun(a.runCh)
case complianceJobStartedMsg:
a.complianceJob = msg.job
a.complianceIndex = msg.index
a.complianceTotal = msg.total
verb := "checking"
activeState := "scanning"
if a.flowMode == ansible.ModeApply {
verb = "applying"
activeState = "fixing"
}
for _, h := range compliance.HostsForJob(a.inv, msg.job) {
h.DriftState = activeState
}
a.statusMsg = fmt.Sprintf("%s %s (%s) — job %d/%d", verb, msg.job.Playbook, msg.job.Label(), msg.index+1, msg.total)
a.errMsg = ""
return a, waitRun(a.runCh)
case complianceJobDoneMsg:
a.complianceJobResults = append(a.complianceJobResults, complianceJobResult{
label: msg.job.Label(),
playbook: msg.job.Playbook,
status: msg.status,
changed: msg.changed,
failed: msg.failed,
})
return a, waitRun(a.runCh)
case tea.MouseMsg:
return a.handleMouse(msg)
case gitOpDoneMsg:
a.configSyncing = false
if msg.err != nil {
a.errMsg = fmt.Sprintf("%s %s: %v", msg.repo, msg.op, msg.err)
a.statusMsg = ""
} else {
a.statusMsg = fmt.Sprintf("%s %s: %s", msg.repo, msg.op, msg.summary)
a.errMsg = ""
if msg.op == "sync" {
switch msg.repo {
case "playbooks":
if a.cfg.PlaybooksGit != nil {
a.cfg.PlaybooksGit.Enabled = true
_ = a.cfg.Save()
}
case "inventory":
if a.cfg.InventoryGit != nil {
a.cfg.InventoryGit.Enabled = true
_ = a.cfg.Save()
}
if inv, err := inventory.Load(a.cfg.EffectiveInventoryPath()); err == nil {
a.inv = inv
}
}
}
}
return a, refreshGitStatusCmd(a.cfg)
case gitStatusMsg:
a.configGitPlaybooks = msg.playbooks
a.configGitInventory = msg.inventory
return a, nil
case runDoneMsg:
a.flowRecord = msg.record
a.flowLog = msg.log
a.applyFindings(msg.findings, msg.record)
a.flowStep = StepDone
if msg.err != nil {
a.errMsg = fmt.Sprintf("run error: %v", msg.err)
}
return a, loadRunsCmd(a.hist)
case complianceDoneMsg:
a.complianceRunning = false
// Any host still showing an active state didn't produce a recap
// (e.g. unreachable) — reset so it doesn't stay stuck.
for _, h := range a.inv.Hosts {
if h.DriftState == "scanning" || h.DriftState == "fixing" {
h.DriftState = "unknown"
}
}
a.flowRecord = msg.record
a.flowLog = msg.log
a.applyFindings(msg.findings, msg.record)
a.complianceSummary = msg.summary
if a.complianceMode {
a.flowStep = StepDone
}
if msg.err != nil {
a.errMsg = fmt.Sprintf("%s: %v", msg.action, msg.err)
a.statusMsg = ""
} else if msg.record != nil {
a.statusMsg = msg.action + " complete"
a.errMsg = ""
}
return a, loadRunsCmd(a.hist)
// ---- VM updater messages ----
case vmOutputLineMsg:
if len(msg.rows) > 0 {
for _, row := range msg.rows {
if progress, ok := vmupdate.ParsePackageProgress(row); ok {
if s, exists := a.vmUpdater.hostStates[row.Server]; exists {
s.status = vmStatusUpdating
s.updatesTotal = progress.Total
if progress.Index > 0 {
s.updatesDone = progress.Index - 1
}
s.currentPackage = progress.PackageName
a.vmUpdater.hostStates[row.Server] = s
}
row.Status = "running"
row.Event = "package_progress"
row.Task = "Update package"
row.Summary = vmupdate.FormatPackageSummary(progress)
row.Item = progress.PackageName
} else if vmupdate.IsPackageResultRow(row) {
if s, exists := a.vmUpdater.hostStates[row.Server]; exists {
s.status = vmStatusUpdating
if s.updatesTotal == 0 {
s.updatesTotal = s.updates
}
if s.updatesDone < s.updatesTotal {
s.updatesDone++
}
s.currentPackage = row.Item
a.vmUpdater.hostStates[row.Server] = s
}
}
a.vmUpdater.vmLogTable.AddRow(row)
}
} else if msg.line != "" {
a.vmUpdater.vmLogTable.AddPlain(msg.line)
}
return a, waitRun(a.vmUpdater.runCh)
case vmCheckStartedMsg:
if s, ok := a.vmUpdater.hostStates[msg.host]; ok {
s.status = vmStatusChecking
a.vmUpdater.hostStates[msg.host] = s
}
return a, waitRun(a.vmUpdater.runCh)
case vmCheckDoneMsg:
s := a.vmUpdater.hostStates[msg.host]
if msg.err != nil {
s.status = vmStatusFailed
} else if msg.updates > 0 {
s.status = vmStatusPending
} else {
s.status = vmStatusUpToDate
}
s.updates = msg.updates
s.hasDocker = msg.docker
s.rebootNeeded = msg.reboot
s.osFamily = msg.osFamily
s.distro = msg.distro
s.lastChecked = time.Now()
a.vmUpdater.hostStates[msg.host] = s
return a, waitRun(a.vmUpdater.runCh)
case vmUpdateStartedMsg:
if s, ok := a.vmUpdater.hostStates[msg.host]; ok {
s.status = vmStatusUpdating
s.updatesTotal = s.updates
s.updatesDone = 0
s.currentPackage = ""
a.vmUpdater.hostStates[msg.host] = s
}
return a, waitRun(a.vmUpdater.runCh)
case vmUpdateDoneMsg:
if s, ok := a.vmUpdater.hostStates[msg.host]; ok {
if msg.ok {
if msg.refreshed {
s.updates = msg.updates
s.hasDocker = msg.docker
s.rebootNeeded = msg.reboot
s.osFamily = msg.osFamily
s.distro = msg.distro
s.lastChecked = time.Now()
} else {
s.updates = 0
}
s.updatesDone = s.updatesTotal
s.currentPackage = ""
if s.updates > 0 {
s.status = vmStatusPending
} else {
s.status = vmStatusDone
}
} else {
s.status = vmStatusFailed
}
a.vmUpdater.hostStates[msg.host] = s
}
return a, waitRun(a.vmUpdater.runCh)
case vmRebootStartedMsg:
if s, ok := a.vmUpdater.hostStates[msg.host]; ok {
s.status = vmStatusUpdating
s.currentPackage = "rebooting"
a.vmUpdater.hostStates[msg.host] = s
}
return a, waitRun(a.vmUpdater.runCh)
case vmRebootDoneMsg:
if s, ok := a.vmUpdater.hostStates[msg.host]; ok {
if msg.ok {
if msg.refreshed {
s.updates = msg.updates
s.hasDocker = msg.docker
s.rebootNeeded = msg.reboot
s.osFamily = msg.osFamily
s.distro = msg.distro
s.lastChecked = time.Now()
} else {
s.rebootNeeded = false
}
s.status = vmStatusDone
} else {
s.status = vmStatusFailed
s.rebootNeeded = true
}
s.currentPackage = ""
a.vmUpdater.hostStates[msg.host] = s
}
return a, waitRun(a.vmUpdater.runCh)
case vmUpdateAllDoneMsg:
phase := a.vmUpdater.phase
a.vmUpdater.running = false
a.vmUpdater.phase = vmPhaseDone
a.vmUpdater.cancelRun = nil
if phase == vmPhaseRebooting && msg.host != "" {
if s := a.vmUpdater.hostStates[msg.host]; s.status == vmStatusFailed {
a.errMsg = fmt.Sprintf("reboot failed for %s — press b to retry", msg.host)
a.statusMsg = ""
} else {
a.statusMsg = fmt.Sprintf("reboot complete for %s", msg.host)
a.errMsg = ""
}
return a, loadRunsCmd(a.hist)
}
var failed, done int
for _, s := range a.vmUpdater.hostStates {
switch s.status {
case vmStatusFailed:
failed++
case vmStatusDone:
done++
default:
}
}
if failed > 0 {
a.errMsg = fmt.Sprintf("%d host(s) failed — press u to retry", failed)
a.statusMsg = ""
} else {
a.statusMsg = fmt.Sprintf("done — %d host(s) updated", done)
a.errMsg = ""
}
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)
case ScreenHostDetails:
return a.updateHostDetails(msg)
case ScreenVMUpdater:
return a.updateVMUpdater(msg)
}
return a, nil
}
@@ -143,23 +143,6 @@ func (a *App) configureConfigEditInputWidth() {
a.configEditInput.Width = w
}
func configFieldPlaceholder(idx int) string {
switch idx {
case configFieldRemote:
return "git@github.com:you/repo.git"
case configFieldBranch:
return "main"
case configFieldPath:
return "~/.ansibletui/playbooks"
case configFieldOnMissing:
return "clone or init"
case configFieldFile:
return "inventory.yml"
default:
return ""
}
}
func (a *App) saveConfigFieldEdit() error {
if a.configRepoFocus == configFocusGeneral {
a.setConfigGeneralFieldValue(a.configFieldFocus, a.configEditInput.Value())
@@ -378,9 +361,9 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a *App) clampConfigFieldFocus() {
max := a.configFieldCount() - 1
if a.configFieldFocus > max {
a.configFieldFocus = max
last := a.configFieldCount() - 1
if a.configFieldFocus > last {
a.configFieldFocus = last
}
}
@@ -411,49 +394,3 @@ func (a *App) configTabEnterCmd() tea.Cmd {
a.configFieldFocus = configGeneralLogLevel
return refreshGitStatusCmd(a.cfg)
}
func (a *App) renderConfigGeneralBlock(selected bool) []string {
prefix := " "
if selected && !a.configEditing {
prefix = tableRowSelected.Render("▶ ") + " "
}
lines := []string{
prefix + boldStyle.Render("General"),
a.renderConfigGeneralFieldLine(configGeneralLogLevel, selected),
}
return lines
}
func (a *App) renderConfigGeneralFieldLine(idx int, selected bool) string {
label := a.configFieldLabel(idx)
val := a.configGeneralFieldValue(idx)
if val == "" {
val = dimStyle.Render("(empty)")
}
line := " " + subtleStyle.Render(label+": ") + val
fieldActive := selected && a.configFieldFocus == idx && !a.configEditing
if fieldActive {
line = " " + tableRowSelected.Render("▸ "+label+": ") + val + dimStyle.Render(" ←→/Enter cycle")
}
return line
}
// renderConfigFieldLine renders one editable git config field for the config tab.
func (a *App) renderConfigFieldLine(repo *config.GitRepo, idx int, repoSelected bool) string {
label := configGitFieldLabel(idx)
val := a.configFieldValue(repo, idx)
if val == "" {
val = dimStyle.Render("(empty)")
}
line := " " + subtleStyle.Render(label+": ") + val
fieldActive := repoSelected && a.configFieldFocus == idx && !a.configEditing
if fieldActive {
line = " " + tableRowSelected.Render("▸ "+label+": ") + val
}
if repoSelected && a.configEditing && a.configFieldFocus == idx {
line = " " + subtleStyle.Render(label+": ") + a.configEditInput.View()
}
return line
}
+66
View File
@@ -0,0 +1,66 @@
package ui
import "ansibletui/internal/config"
func (a *App) renderConfigGeneralBlock(selected bool) []string {
prefix := " "
if selected && !a.configEditing {
prefix = tableRowSelected.Render("▶ ") + " "
}
lines := []string{
prefix + boldStyle.Render("General"),
a.renderConfigGeneralFieldLine(configGeneralLogLevel, selected),
}
return lines
}
func (a *App) renderConfigGeneralFieldLine(idx int, selected bool) string {
label := a.configFieldLabel(idx)
val := a.configGeneralFieldValue(idx)
if val == "" {
val = dimStyle.Render("(empty)")
}
line := " " + subtleStyle.Render(label+": ") + val
fieldActive := selected && a.configFieldFocus == idx && !a.configEditing
if fieldActive {
line = " " + tableRowSelected.Render("▸ "+label+": ") + val + dimStyle.Render(" ←→/Enter cycle")
}
return line
}
// renderConfigFieldLine renders one editable git config field for the config tab.
func (a *App) renderConfigFieldLine(repo *config.GitRepo, idx int, repoSelected bool) string {
label := configGitFieldLabel(idx)
val := a.configFieldValue(repo, idx)
if val == "" {
val = dimStyle.Render("(empty)")
}
line := " " + subtleStyle.Render(label+": ") + val
fieldActive := repoSelected && a.configFieldFocus == idx && !a.configEditing
if fieldActive {
line = " " + tableRowSelected.Render("▸ "+label+": ") + val
}
if repoSelected && a.configEditing && a.configFieldFocus == idx {
line = " " + subtleStyle.Render(label+": ") + a.configEditInput.View()
}
return line
}
func configFieldPlaceholder(idx int) string {
switch idx {
case configFieldRemote:
return "git@github.com:you/repo.git"
case configFieldBranch:
return "main"
case configFieldPath:
return "~/.ansibletui/playbooks"
case configFieldOnMissing:
return "clone or init"
case configFieldFile:
return "inventory.yml"
default:
return ""
}
}
+438
View File
@@ -0,0 +1,438 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
const vmStaleThreshold = 30 * time.Minute
type dashboardAttentionItem struct {
host *inventory.Host
score int
drift string
updates string
reboot string
}
func (a *App) renderDashboardTab(w, bodyH int) string {
if bodyH < 10 {
return a.renderServersTab(w, bodyH)
}
topH := 7
if bodyH < 22 {
topH = 6
}
midH := bodyH - topH - 1
if midH < 7 {
midH = 7
}
gap := " "
topW := (w - 2) / 3
if topW < 24 {
topW = max(18, (w-2)/2)
}
activeW := w - topW*2 - 2
if activeW < 22 {
activeW = topW
}
top := lipgloss.JoinHorizontal(lipgloss.Top,
a.renderDashboardFleetPanel(topW, topH), gap,
a.renderDashboardUpdatesPanel(topW, topH), gap,
a.renderDashboardActivePanel(activeW, topH),
)
leftW := w * 72 / 100
if leftW < 40 {
leftW = max(20, w/2)
}
rightW := w - leftW - 1
if rightW < 24 {
rightW = 24
}
mid := lipgloss.JoinHorizontal(lipgloss.Top,
a.renderDashboardAttentionPanel(leftW, midH), gap,
a.renderDashboardHostActionsPanel(rightW, midH),
)
return clampRenderedHeight(lipgloss.JoinVertical(lipgloss.Left, top, mid), bodyH)
}
func (a *App) renderDashboardFleetPanel(w, h int) string {
total, clean, drift, failed, unknown := fleetCounts(a.inv.Hosts)
lines := []string{
panelHeaderStyle.Render("Fleet Health"),
tableHeaderStyle.Render("Hosts Clean Drift Failed Unknown"),
fmt.Sprintf("%-6d %-6d %-6d %-7d %d", total, clean, drift, failed, unknown),
"last scan " + dimStyle.Render(a.latestComplianceLabel()),
}
return dashboardPanel(w, h, lines)
}
func (a *App) renderDashboardUpdatesPanel(w, h int) string {
pending, reboot, docker, stale, never := a.vmUpdateCounts()
lines := []string{
panelHeaderStyle.Render("Update Readiness"),
tableHeaderStyle.Render("Pending Reboot Docker Stale Never"),
fmt.Sprintf("%-8s %-7s %-7s %-6d %d",
vmBadgePending.Render(fmt.Sprintf("%d", pending)),
runChangedStyle.Render(fmt.Sprintf("%d", reboot)),
vmBadgeDone.Render(fmt.Sprintf("%d", docker)),
stale,
never),
"freshest " + dimStyle.Render(a.freshestUpdateLabel()),
}
return dashboardPanel(w, h, lines)
}
func (a *App) renderDashboardActivePanel(w, h int) string {
lines := []string{panelHeaderStyle.Render("Active Work")}
switch {
case a.complianceRunning:
elapsed := time.Since(a.runStarted).Round(time.Second)
lines = append(lines,
statusScanning.Render(Icons.Running+" "+a.complianceAction),
truncate(a.statusMsg, max(12, w-6)),
renderRunProgress(a.complianceIndex, a.complianceTotal, max(10, w-8)),
dimStyle.Render("elapsed "+elapsed.String()),
)
if elapsed > time.Minute {
lines = append(lines, runChangedStyle.Render("slow remote response"))
}
case a.vmUpdater.running:
elapsed := time.Since(a.runStarted).Round(time.Second)
lines = append(lines,
vmBadgeUpdating.Render(Icons.Running+" "+vmPhaseLabel(a.vmUpdater.phase)),
truncate(a.vmUpdater.selectedHost, max(10, w-6)),
a.dashboardVMProgressLine(w-6),
dimStyle.Render("elapsed "+elapsed.String()),
)
case a.errMsg != "":
lines = append(lines, formErrorStyle.Render(truncate(a.errMsg, max(10, w-6))))
case a.statusMsg != "":
lines = append(lines, dimStyle.Render(truncate(a.statusMsg, max(10, w-6))))
default:
lines = append(lines, dimStyle.Render("No active runs"))
}
return dashboardPanel(w, h, lines)
}
func (a *App) renderDashboardAttentionPanel(w, h int) string {
lines := []string{
panelHeaderStyle.Render("Host Work Queue"),
tableHeaderStyle.Render("Host Drift Updates Reboot"),
}
items := a.dashboardAttentionItems()
maxRows := h - 4
if maxRows < 1 {
maxRows = 1
}
start := a.dashboardAttentionWindowStart(items, maxRows)
visibleRows := maxRows
if start > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↑ %d more", start)))
visibleRows--
}
if visibleRows > 0 && start+visibleRows < len(items) {
visibleRows--
}
for i := start; i < len(items) && i < start+visibleRows; i++ {
item := items[i]
name := padRight(item.host.Name, 22)
line := name + " " +
padANSI(item.drift, 7) + " " +
padANSI(item.updates, 8) + " " +
item.reboot
if item.host == a.selectedServer() {
line = tableRowSelected.Render(line)
}
lines = append(lines, line)
}
if hidden := len(items) - start - visibleRows; hidden > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↓ %d more", hidden)))
}
if len(items) == 0 {
lines = append(lines, runCleanStyle.Render("No hosts need attention"))
}
return dashboardPanel(w, h, lines)
}
func (a *App) dashboardAttentionWindowStart(items []dashboardAttentionItem, maxRows int) int {
if len(items) == 0 || maxRows <= 0 {
return 0
}
selected := a.selectedServer()
if selected == nil {
return 0
}
selectedIndex := -1
for i, item := range items {
if item.host.Name == selected.Name {
selectedIndex = i
break
}
}
if selectedIndex < 0 || selectedIndex < maxRows {
return 0
}
start := selectedIndex - maxRows + 1
if start > len(items)-maxRows {
start = len(items) - maxRows
}
if start < 0 {
return 0
}
return start
}
func (a *App) renderDashboardHostActionsPanel(w, h int) string {
lines := []string{panelHeaderStyle.Render("Host Actions")}
host := a.selectedServer()
if host == nil {
lines = append(lines, dimStyle.Render("No server selected"))
return dashboardPanel(w, h, lines)
}
state := a.vmUpdater.hostStates[host.Name]
lines = append(lines,
tableHeaderStyle.Render("Target"),
boldStyle.Render(host.Name)+" "+dimStyle.Render(host.Group),
dimStyle.Render(host.AnsibleHost),
"",
tableHeaderStyle.Render("State"),
"drift: "+renderDrift(host.DriftState),
fmt.Sprintf("findings: %s", dimStyle.Render(fmt.Sprintf("%d", ansible.CountDrift(a.hostFindings[host.Name])))),
"updates: "+a.renderUpdateStateSummary(state),
"",
tableHeaderStyle.Render("Actions"),
dimStyle.Render("c refresh f fix u update"),
dimStyle.Render("Enter details"),
)
return dashboardPanel(w, h, lines)
}
func dashboardPanel(w, h int, lines []string) string {
innerW := max(1, w-4)
target := max(1, h-2)
contentLines := strings.Split(strings.Join(lines, "\n"), "\n")
if len(contentLines) > target {
contentLines = contentLines[:target]
}
for len(contentLines) < target {
contentLines = append(contentLines, "")
}
return panelStyle.Width(innerW).Render(strings.Join(contentLines, "\n"))
}
func clampRenderedHeight(rendered string, maxHeight int) string {
if maxHeight <= 0 {
return ""
}
lines := strings.Split(rendered, "\n")
if len(lines) <= maxHeight {
return rendered
}
return strings.Join(lines[:maxHeight], "\n")
}
func (a *App) latestComplianceLabel() string {
for _, run := range a.runs {
if run == nil {
continue
}
if run.Mode == "check" || run.Mode == "check+diff" {
return run.TimeLabel()
}
}
return "never"
}
func (a *App) freshestUpdateLabel() string {
var newest time.Time
for _, state := range a.vmUpdater.hostStates {
if state.lastChecked.After(newest) {
newest = state.lastChecked
}
}
if newest.IsZero() {
return "never"
}
return freshnessLabel(newest)
}
func (a *App) vmUpdateCounts() (pending, reboot, docker, stale, never int) {
for _, h := range a.inv.Hosts {
state := a.vmUpdater.hostStates[h.Name]
if state.updates > 0 {
pending += state.updates
}
if state.rebootNeeded {
reboot++
}
if state.hasDocker {
docker++
}
if state.lastChecked.IsZero() {
never++
} else if time.Since(state.lastChecked) > vmStaleThreshold {
stale++
}
}
return
}
func (a *App) dashboardAttentionItems() []dashboardAttentionItem {
var items []dashboardAttentionItem
seen := map[string]struct{}{}
for _, h := range a.filteredServers() {
key := strings.ToLower(strings.TrimSpace(h.Name))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
state := a.vmUpdater.hostStates[h.Name]
score := 0
item := dashboardAttentionItem{
host: h,
drift: dimStyle.Render("0"),
updates: dimStyle.Render("0"),
reboot: dimStyle.Render("0"),
}
switch h.DriftState {
case "failed", "unreachable":
score += 100
item.drift = runFailedStyle.Render(h.DriftState)
case "drift":
score += 70
item.drift = runChangedStyle.Render(fmt.Sprintf("%d", ansible.CountDrift(a.hostFindings[h.Name])))
}
if state.status == vmStatusFailed {
score += 60
item.updates = vmBadgeFailed.Render("failed")
}
if state.updates > 0 {
score += 40
item.updates = vmBadgePending.Render(fmt.Sprintf("%d", state.updates))
}
if state.rebootNeeded {
score += 30
item.reboot = runChangedStyle.Render("1")
}
if state.lastChecked.IsZero() {
score += 10
if state.updates == 0 && state.status != vmStatusFailed {
item.updates = dimStyle.Render("?")
}
} else if time.Since(state.lastChecked) > vmStaleThreshold {
score += 5
if state.updates == 0 && state.status != vmStatusFailed {
item.updates = dimStyle.Render("stale")
}
}
if score > 0 {
item.score = score
items = append(items, item)
}
}
return items
}
func (a *App) moveDashboardQueueCursor(delta int) {
items := a.dashboardAttentionItems()
if len(items) == 0 {
a.moveCursorDown()
return
}
selected := a.selectedServer()
idx := 0
if selected != nil {
for i, item := range items {
if item.host.Name == selected.Name {
idx = i
break
}
}
}
idx += delta
if idx < 0 {
idx = 0
}
if idx >= len(items) {
idx = len(items) - 1
}
a.selectServerByName(items[idx].host.Name)
}
func (a *App) renderUpdateStateSummary(state vmHostState) string {
switch {
case state.status == vmStatusChecking:
return vmBadgeChecking.Render("checking")
case state.status == vmStatusUpdating:
return vmBadgeUpdating.Render("updating")
case state.status == vmStatusFailed:
return vmBadgeFailed.Render("failed")
case state.updates > 0:
return vmBadgePending.Render(fmt.Sprintf("%d pending", state.updates)) + " " + dimStyle.Render(freshnessLabel(state.lastChecked))
case !state.lastChecked.IsZero():
return vmBadgeUpToDate.Render("current") + " " + dimStyle.Render(freshnessLabel(state.lastChecked))
default:
return dimStyle.Render("never checked")
}
}
func (a *App) dashboardVMProgressLine(width int) string {
host := a.vmUpdater.selectedHost
if host == "" {
return dimStyle.Render("starting")
}
state := a.vmUpdater.hostStates[host]
if state.updatesTotal > 0 {
return renderRunProgress(state.updatesDone, state.updatesTotal, width)
}
if state.currentPackage != "" {
return dimStyle.Render(truncate(state.currentPackage, width))
}
return dimStyle.Render(host)
}
func (a *App) dashboardRunLine(run *history.RunRecord, width int) string {
if run == nil {
return ""
}
pb := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(run.Playbook, "playbooks/"), ".yml"), ".yaml")
duration := run.EndTime.Sub(run.StartTime).Round(time.Second)
if duration < 0 {
duration = 0
}
line := fmt.Sprintf("%s %-18s %-12s %-12s %s",
run.TimeLabel(), truncate(pb, 18), truncate(run.Host, 12), run.StatusSummary(), duration)
return truncate(line, width)
}
func freshnessLabel(t time.Time) string {
if t.IsZero() {
return "never"
}
age := time.Since(t)
if age < time.Minute {
return "fresh"
}
if age < time.Hour {
return fmt.Sprintf("%dm ago", int(age.Minutes()))
}
if age < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(age.Hours()))
}
return t.Format("Jan 2")
}
+148 -12
View File
@@ -3,6 +3,7 @@ package ui
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -63,20 +64,20 @@ func TestComplianceBatchKeyParallelizesUniversalChecks(t *testing.T) {
{Playbook: "dns.yml", Limit: "dns", Group: "dns"},
}
first := complianceBatchKey(jobs[0], ansible.ModeCheckDiff, 0)
second := complianceBatchKey(jobs[1], ansible.ModeCheckDiff, 1)
first := compliance.BatchKey(jobs[0], ansible.ModeCheckDiff, 0)
second := compliance.BatchKey(jobs[1], ansible.ModeCheckDiff, 1)
if first == second {
t.Fatalf("universal check jobs should use distinct batch keys, got %q", first)
}
if got := complianceBatchKey(jobs[2], ansible.ModeCheckDiff, 2); got != "dns" {
if got := compliance.BatchKey(jobs[2], ansible.ModeCheckDiff, 2); got != "dns" {
t.Fatalf("grouped check job key = %q, want dns", got)
}
if got := complianceBatchKey(jobs[0], ansible.ModeApply, 0); got != "" {
if got := compliance.BatchKey(jobs[0], ansible.ModeApply, 0); got != "" {
t.Fatalf("universal apply job key = %q, want empty group key", got)
}
if got := complianceBatchKey(jobs[1], ansible.ModeApply, 1); got != "" {
if got := compliance.BatchKey(jobs[1], ansible.ModeApply, 1); got != "" {
t.Fatalf("second universal apply job key = %q, want empty group key", got)
}
}
@@ -442,18 +443,24 @@ func TestRecentRunsOneLinePerRun(t *testing.T) {
}
func TestRecentRunsSeqNumberDescending(t *testing.T) {
// The sequence number logic: runs[0] is newest, gets highest seq number.
// runs[0] is newest and gets the highest sequence number in rendered output.
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
app.height = 40
app.runs = []*history.RunRecord{
{Playbook: "playbooks/a.yml", Host: "h1", Mode: "check", Status: "clean"},
{Playbook: "playbooks/b.yml", Host: "h2", Mode: "check", Status: "drift"},
}
// runs[0] → seqNum = len(runs) - 0 = 2
// runs[1] → seqNum = len(runs) - 1 = 1
seqFirst := len(app.runs) - 0
seqSecond := len(app.runs) - 1
if seqFirst <= seqSecond {
t.Fatalf("first run seq %d should be > second run seq %d", seqFirst, seqSecond)
rendered := app.renderRecentRunsPanel(60, 15)
// Sequence numbers are rendered as right-aligned integers ("%3d playbook").
// " 2" (newest) must appear before " 1" (oldest) because runs render newest-first.
idx2 := strings.Index(rendered, " 2 ")
idx1 := strings.Index(rendered, " 1 ")
if idx2 < 0 || idx1 < 0 {
t.Fatalf("sequence numbers 1 and 2 not found in:\n%s", rendered)
}
if idx2 >= idx1 {
t.Fatalf("seq 2 (newest) should appear before seq 1 (oldest), got idx2=%d idx1=%d", idx2, idx1)
}
}
@@ -470,3 +477,132 @@ func TestRecentRunsStylesDifferByStatus(t *testing.T) {
t.Error("drift and failed run styles should have different foreground colors")
}
}
func TestSelectedHostComplianceJobsLimitsMappedJobsToHost(t *testing.T) {
app := New(&config.Config{
Compliance: config.Compliance{
Universal: []config.CompliancePlaybook{{Playbook: "site.yml"}},
Groups: map[string][]config.CompliancePlaybook{
"web": {{Playbook: "web.yml", Tags: []string{"sudo"}}},
"db": {{Playbook: "db.yml"}},
},
},
}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", Group: "web"},
{Name: "db01", Group: "db"},
}}, history.New(t.TempDir()))
jobs := app.selectedHostComplianceJobs(app.inv.Hosts[0])
if len(jobs) != 2 {
t.Fatalf("jobs = %d, want 2", len(jobs))
}
for _, job := range jobs {
if job.Limit != "web01" {
t.Fatalf("job %s limit = %q, want web01", job.Playbook, job.Limit)
}
if job.Playbook == "db.yml" {
t.Fatal("selected web host should not include db group mapping")
}
}
}
func TestDashboardRendersCorePanels(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", Group: "web", DriftState: "drift"},
}}, history.New(t.TempDir()))
app.width = 120
app.height = 36
app.vmUpdater.hostStates["web01"] = vmHostState{
status: vmStatusPending,
updates: 4,
lastChecked: time.Now(),
}
app.runs = []*history.RunRecord{{
Playbook: "site.yml",
Host: "web01",
Mode: "check+diff",
Status: "drift",
StartTime: time.Now().Add(-2 * time.Minute),
EndTime: time.Now().Add(-time.Minute),
Changed: 2,
}}
rendered := app.renderDashboardTab(100, 28)
for _, want := range []string{"Fleet Health", "Update Readiness", "Host Work Queue", "Host Actions", "Host", "Drift", "Updates", "Reboot"} {
if !strings.Contains(rendered, want) {
t.Fatalf("dashboard missing %q:\n%s", want, rendered)
}
}
for _, unwanted := range []string{"Compliance", "Check"} {
if strings.Contains(rendered, unwanted) {
t.Fatalf("dashboard still renders dropped column %q:\n%s", unwanted, rendered)
}
}
}
func TestDashboardDoesNotExceedBodyHeight(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", Group: "web", DriftState: "drift"},
{Name: "web02", Group: "web", DriftState: "failed"},
}}, history.New(t.TempDir()))
app.width = 120
app.height = 32
bodyH := 20
rendered := app.renderDashboardTab(100, bodyH)
if got := lipgloss.Height(rendered); got > bodyH {
t.Fatalf("dashboard height = %d, want <= %d\n%s", got, bodyH, rendered)
}
}
func TestDashboardQueueNavigationUsesVisibleAttentionRows(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "adguard-1", Group: "dns"},
{Name: "kube-1", Group: "k8s", DriftState: "drift"},
{Name: "node-1", Group: "swarm"},
}}, history.New(t.TempDir()))
app.sidebarTab = TabDashboard
app.activePanel = PanelServers
app.vmUpdater.hostStates["adguard-1"] = vmHostState{status: vmStatusFailed, lastChecked: time.Now()}
app.vmUpdater.hostStates["node-1"] = vmHostState{updates: 1, lastChecked: time.Now()}
app.selectServerByName("adguard-1")
app.moveDashboardQueueCursor(1)
if got := app.selectedServer(); got == nil || got.Name != "kube-1" {
t.Fatalf("selected host = %#v, want kube-1", got)
}
app.moveDashboardQueueCursor(1)
if got := app.selectedServer(); got == nil || got.Name != "node-1" {
t.Fatalf("selected host = %#v, want node-1", got)
}
}
func TestDashboardAttentionItemsCollapseDuplicateHostNames(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "node-1", Group: "swarm"},
{Name: " NODE-1 ", Group: "swarm"},
}}, history.New(t.TempDir()))
app.vmUpdater.hostStates["node-1"] = vmHostState{updates: 1, lastChecked: time.Now()}
items := app.dashboardAttentionItems()
if len(items) != 1 {
t.Fatalf("items = %d, want 1: %#v", len(items), items)
}
}
func TestDashboardQueueSelectedRowShowsFullUpdateResult(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "xen-orchestra", Group: "appliance"},
}}, history.New(t.TempDir()))
app.vmUpdater.hostStates["xen-orchestra"] = vmHostState{updates: 3, lastChecked: time.Now()}
app.selectServerByName("xen-orchestra")
rendered := app.renderDashboardAttentionPanel(90, 12)
if !strings.Contains(rendered, "3") {
t.Fatalf("selected queue row hid update result:\n%s", rendered)
}
if strings.Contains(rendered, "…") {
t.Fatalf("selected queue row clipped update result:\n%s", rendered)
}
}
+387
View File
@@ -0,0 +1,387 @@
package ui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.filtering {
return a.updateFilter(msg)
}
if a.sidebarTab == TabConfig {
if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, keys.Quit) {
return a, tea.Quit
}
return a.updateConfigTab(msg)
}
if a.sidebarTab == TabVMUpdate {
if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, keys.Quit) {
return a, tea.Quit
}
if km, ok := msg.(tea.KeyMsg); ok && !key.Matches(km, keys.ShiftTab) {
return a.updateVMUpdater(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):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 1) % 5
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
if a.sidebarTab == TabVMUpdate {
return a, a.openVMUpdaterScreen()
}
return a, nil
case key.Matches(km, keys.ShiftTab):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 4) % 5
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
if a.sidebarTab == TabVMUpdate {
return a, a.openVMUpdaterScreen()
}
return a, nil
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):
if a.sidebarTab == TabDashboard {
a.moveDashboardQueueCursor(-1)
return a, nil
}
a.moveCursorUp()
return a, nil
case key.Matches(km, keys.Down):
if a.sidebarTab == TabDashboard {
a.moveDashboardQueueCursor(1)
return a, nil
}
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.Check):
return a.openComplianceScan()
case key.Matches(km, keys.Fix):
return a.openComplianceApply()
case key.Matches(km, keys.Update):
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
if h := a.selectedServer(); h != nil {
a.syncVMSelectedHost(h.Name)
state := a.vmUpdater.hostStates[h.Name]
if state.status == vmStatusPending || state.status == vmStatusFailed {
return a, a.startVMUpdatesForHosts([]*inventory.Host{h})
}
return a, a.startVMPreCheckForHosts([]*inventory.Host{h}, false)
}
return a, nil
case key.Matches(km, keys.Enter):
if a.activePanel == PanelRuns || a.sidebarTab == TabJobs {
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
} else {
if h := a.selectedServer(); h != nil {
a.openHostDetailsScreen(h.Name)
}
}
return a, nil
case key.Matches(km, keys.MenuToggle):
a.sidebarExpanded = !a.sidebarExpanded
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 {
last := len(a.filteredServers()) - 1
if a.serverCursor < last {
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.complianceMode = false
a.complianceJobs = nil
a.complianceSummary = compliance.Summary{}
a.flowHost = host
a.flowMode = defaultMode
a.flowStep = StepPlaybook
a.outputLines = nil
a.logTable.Reset()
a.flowRecord = nil
a.flowLog = nil
a.showRunLogs = false
a.modeCursor = int(defaultMode)
a.loadPlaybooks()
}
func (a *App) openComplianceScan() (tea.Model, tea.Cmd) {
h := a.selectedServer()
if h == nil {
a.errMsg = "no server selected"
a.statusMsg = ""
return a, nil
}
jobs := a.selectedHostComplianceJobs(h)
if len(jobs) == 0 {
a.errMsg = fmt.Sprintf("no compliance playbooks mapped for %s", h.Name)
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("refreshing compliance for %s: %d mapped checks", h.Name, len(jobs))
return a, a.startComplianceRun(jobs, ansible.ModeCheckDiff, true, "compliance refresh")
}
func (a *App) openComplianceApply() (tea.Model, tea.Cmd) {
h := a.selectedServer()
if h == nil {
a.errMsg = "no server selected"
a.statusMsg = ""
return a, nil
}
jobs := a.selectedHostComplianceJobs(h)
if len(jobs) == 0 {
a.errMsg = fmt.Sprintf("no compliance playbooks mapped for %s", h.Name)
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
findings, source := a.driftFixFindingsForHost(h.Name)
plan := compliance.PlanDriftFix(jobs, a.inv, findings)
if len(plan.Jobs) == 0 {
message := "no drift to fix"
if plan.SkippedIneligible > 0 {
message = fmt.Sprintf("no mapped drift to fix; %d drift item(s) do not match compliance mappings", plan.SkippedIneligible)
}
a.openComplianceApplyNoWork(message)
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("fixing drift on %s: %d job(s)", h.Name, len(plan.Jobs))
if plan.SkippedIneligible > 0 {
a.statusMsg += fmt.Sprintf(" (%d unmapped skipped)", plan.SkippedIneligible)
}
if source != "" {
a.statusMsg += " from " + source
}
return a, a.startComplianceRun(plan.Jobs, ansible.ModeApply, true, "drift fix")
}
func (a *App) openComplianceApplyNoWork(message string) {
a.screen = ScreenCmdFlow
a.complianceMode = true
a.complianceRunning = false
a.complianceAction = "drift fix"
a.complianceJob = compliance.Job{}
a.complianceIndex = 0
a.complianceTotal = 0
a.complianceSummary = compliance.Summary{Status: "clean"}
a.flowHost = "fleet"
a.flowPlaybook = "drift fix"
a.flowMode = ansible.ModeApply
a.flowStep = StepDone
a.flowRecord = &history.RunRecord{
Playbook: "drift fix",
Host: "fleet",
Mode: ansible.ModeApply.String(),
Status: "clean",
StartTime: time.Now(),
EndTime: time.Now(),
}
a.flowLog = []byte(message + "\n")
a.outputLines = []string{message}
a.outputVp.SetContent(message)
a.logTable.Reset()
a.logTable.SetPlain(message)
a.statusMsg = ""
a.errMsg = message
}
func (a *App) driftFixFindings() ([]ansible.Finding, string) {
findings := ansible.FlattenHostFindings(a.hostFindings)
if ansible.CountByStatus(findings, "changed") > 0 {
return findings, ""
}
findings, rec := a.latestSavedDriftFindings()
if len(findings) == 0 {
return nil, ""
}
a.applyFindings(findings, rec)
if rec == nil {
return findings, "saved findings"
}
return findings, rec.TimeLabel()
}
func (a *App) driftFixFindingsForHost(host string) ([]ansible.Finding, string) {
findings, source := a.driftFixFindings()
if len(findings) == 0 {
return nil, source
}
return ansible.FindingsForHost(findings, host), source
}
func (a *App) latestSavedDriftFindings() ([]ansible.Finding, *history.RunRecord) {
records := a.runs
if len(records) == 0 && a.hist != nil {
records, _ = a.hist.List(maxRecentRuns)
}
if findings, rec := a.latestSavedDriftFindingsMatching(records, func(r *history.RunRecord) bool {
return r.Host == "fleet"
}); len(findings) > 0 {
return findings, rec
}
return a.latestSavedDriftFindingsMatching(records, func(*history.RunRecord) bool { return true })
}
func (a *App) latestSavedDriftFindingsMatching(records []*history.RunRecord, match func(*history.RunRecord) bool) ([]ansible.Finding, *history.RunRecord) {
if a.hist == nil {
return nil, nil
}
for _, rec := range records {
if rec == nil || !match(rec) || rec.FindingsFile == "" || !isCheckMode(rec.Mode) {
continue
}
findings, err := a.hist.LoadFindings(rec)
if err != nil || ansible.CountByStatus(findings, "changed") == 0 {
continue
}
return findings, rec
}
return nil, nil
}
func isCheckMode(mode string) bool {
return mode == ansible.ModeCheck.String() || mode == ansible.ModeCheckDiff.String()
}
// syncActivePanelToTab keeps activePanel in sync with the sidebar tab selection.
func (a *App) syncActivePanelToTab() {
switch a.sidebarTab {
case TabDashboard:
a.activePanel = PanelServers
case TabServers:
a.activePanel = PanelServers
case TabJobs:
a.activePanel = PanelRuns
}
}
+11 -405
View File
@@ -2,355 +2,17 @@ package ui
import (
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
// ---- 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)
}
if a.sidebarTab == TabConfig {
if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, keys.Quit) {
return a, tea.Quit
}
return a.updateConfigTab(msg)
}
if a.sidebarTab == TabVMUpdate {
if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, keys.Quit) {
return a, tea.Quit
}
// Tab cycles panes within the VM updater. ShiftTab still cycles sidebar tabs.
if km, ok := msg.(tea.KeyMsg); ok && !key.Matches(km, keys.ShiftTab) {
return a.updateVMUpdater(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):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 1) % 4
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
if a.sidebarTab == TabVMUpdate {
return a, a.openVMUpdaterScreen()
}
return a, nil
case key.Matches(km, keys.ShiftTab):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 3) % 4
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
if a.sidebarTab == TabVMUpdate {
return a, a.openVMUpdaterScreen()
}
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.Check):
return a.openComplianceScan()
case key.Matches(km, keys.Update):
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
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 || a.sidebarTab == TabJobs {
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
} else {
if h := a.selectedServer(); h != nil {
a.openHostDetailsScreen(h.Name)
}
}
return a, nil
case key.Matches(km, keys.MenuToggle):
a.sidebarExpanded = !a.sidebarExpanded
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.complianceMode = false
a.complianceJobs = nil
a.complianceSummary = compliance.Summary{}
a.flowHost = host
a.flowMode = defaultMode
a.flowStep = StepPlaybook
a.outputLines = nil
a.logTable.Reset()
a.flowRecord = nil
a.flowLog = nil
a.showRunLogs = false
a.modeCursor = int(defaultMode)
a.loadPlaybooks()
}
func (a *App) openComplianceScan() (tea.Model, tea.Cmd) {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
a.errMsg = "no compliance playbooks mapped; use Enter for a manual check or add compliance mappings to config"
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("starting compliance scan: %d mapped checks", len(jobs))
return a, a.startComplianceRun(jobs, ansible.ModeCheckDiff, true, "compliance scan")
}
func (a *App) openComplianceApply() (tea.Model, tea.Cmd) {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
a.errMsg = "no compliance playbooks mapped; add compliance.yaml or config compliance mappings"
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
findings, source := a.driftFixFindings()
plan := compliance.PlanDriftFix(jobs, a.inv, findings)
if len(plan.Jobs) == 0 {
message := "no drift to fix"
if plan.SkippedIneligible > 0 {
message = fmt.Sprintf("no mapped drift to fix; %d drift item(s) do not match compliance mappings", plan.SkippedIneligible)
}
a.openComplianceApplyNoWork(message)
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("fixing drift: %d job(s)", len(plan.Jobs))
if plan.SkippedIneligible > 0 {
a.statusMsg += fmt.Sprintf(" (%d unmapped skipped)", plan.SkippedIneligible)
}
if source != "" {
a.statusMsg += " from " + source
}
return a, a.startComplianceRun(plan.Jobs, ansible.ModeApply, true, "drift fix")
}
func (a *App) openComplianceApplyNoWork(message string) {
a.screen = ScreenCmdFlow
a.complianceMode = true
a.complianceRunning = false
a.complianceAction = "drift fix"
a.complianceJob = compliance.Job{}
a.complianceIndex = 0
a.complianceTotal = 0
a.complianceSummary = compliance.Summary{Status: "clean"}
a.flowHost = "fleet"
a.flowPlaybook = "drift fix"
a.flowMode = ansible.ModeApply
a.flowStep = StepDone
a.flowRecord = &history.RunRecord{
Playbook: "drift fix",
Host: "fleet",
Mode: ansible.ModeApply.String(),
Status: "clean",
StartTime: time.Now(),
EndTime: time.Now(),
}
a.flowLog = []byte(message + "\n")
a.outputLines = []string{message}
a.outputVp.SetContent(message)
a.logTable.Reset()
a.logTable.SetPlain(message)
a.statusMsg = ""
a.errMsg = message
}
func (a *App) driftFixFindings() ([]ansible.Finding, string) {
findings := flattenHostFindings(a.hostFindings)
if countStatus(findings, "changed") > 0 {
return findings, ""
}
findings, rec := a.latestSavedDriftFindings()
if len(findings) == 0 {
return nil, ""
}
a.applyFindings(findings, rec)
if rec == nil {
return findings, "saved findings"
}
return findings, rec.TimeLabel()
}
func (a *App) latestSavedDriftFindings() ([]ansible.Finding, *history.RunRecord) {
records := a.runs
if len(records) == 0 && a.hist != nil {
records, _ = a.hist.List(maxRecentRuns)
}
if findings, rec := a.latestSavedDriftFindingsMatching(records, func(r *history.RunRecord) bool {
return r.Host == "fleet"
}); len(findings) > 0 {
return findings, rec
}
return a.latestSavedDriftFindingsMatching(records, func(*history.RunRecord) bool { return true })
}
func (a *App) latestSavedDriftFindingsMatching(records []*history.RunRecord, match func(*history.RunRecord) bool) ([]ansible.Finding, *history.RunRecord) {
if a.hist == nil {
return nil, nil
}
for _, rec := range records {
if rec == nil || !match(rec) || rec.FindingsFile == "" || !isCheckMode(rec.Mode) {
continue
}
findings, err := a.hist.LoadFindings(rec)
if err != nil || countStatus(findings, "changed") == 0 {
continue
}
return findings, rec
}
return nil, nil
}
func isCheckMode(mode string) bool {
return mode == ansible.ModeCheck.String() || mode == ansible.ModeCheckDiff.String()
}
// ---- View ----
func (a *App) viewHome() string {
mainW := a.width
if mainW < 20 {
@@ -368,8 +30,6 @@ func (a *App) viewHome() string {
body := a.renderBody(mainW, bodyH)
// Clamp body to exactly bodyH lines to prevent terminal scroll from pushing
// title bar off-screen when panel content overflows its allocated height.
if lines := strings.Split(body, "\n"); len(lines) > bodyH {
body = strings.Join(lines[:bodyH], "\n")
}
@@ -397,7 +57,6 @@ func (a *App) renderBody(w, bodyH int) string {
}
func (a *App) renderSidebar(w, h int) string {
// Hamburger row.
hamburgerIcon := navStyle.Width(w).Align(lipgloss.Center).Render(Icons.Hamburger)
hamburgerRow := zone.Mark("hamburger", hamburgerIcon)
@@ -407,15 +66,13 @@ func (a *App) renderSidebar(w, h int) string {
id int
}
tabs := []tabDef{
{Icons.Dashboard, "Dashboard", TabDashboard},
{Icons.Servers, "Servers", TabServers},
{Icons.Jobs, "Jobs", TabJobs},
{Icons.Updates, "Updates", TabVMUpdate},
{Icons.Jobs, "Jobs", TabJobs},
{Icons.Config, "Config", TabConfig},
}
// VS Code-style: active item gets a thick left accent bar + tinted background.
// Inactive items sit in open space with generous vertical padding.
// The left border costs 1 column, so the inner area is w-1.
innerW := w - 1
if innerW < 2 {
innerW = 2
@@ -435,7 +92,7 @@ func (a *App) renderSidebar(w, h int) string {
accentInactive := lipgloss.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color("#1a2530")). // near-invisible border keeps alignment
BorderForeground(lipgloss.Color("#1a2530")).
Foreground(colorDim).
Width(innerW).
Align(lipgloss.Center).
@@ -470,6 +127,8 @@ func (a *App) renderSidebar(w, h int) string {
func (a *App) renderMainContent(w, bodyH int) string {
switch a.sidebarTab {
case TabDashboard:
return a.renderDashboardTab(w, bodyH)
case TabJobs:
return a.renderJobsTab(w, bodyH)
case TabVMUpdate:
@@ -482,7 +141,6 @@ func (a *App) renderMainContent(w, bodyH int) string {
}
func (a *App) renderServersTab(w, bodyH int) string {
// Dynamic top height: grows when compliance results are present.
topH := 5
if n := len(a.complianceJobResults); n > 0 {
topH = 5 + n + 2
@@ -506,7 +164,6 @@ func (a *App) renderServersTab(w, bodyH int) string {
}
rightW := w - serversW - 1
// Server detail is compact now (fewer fields); give it a fixed small height.
detailH := 6
if contentH < 16 {
detailH = 5
@@ -516,7 +173,6 @@ func (a *App) renderServersTab(w, bodyH int) string {
runsH = 4
}
// Fleet summary spans full width so compliance results have room.
fleet := a.renderFleetSummaryPanel(w, topH)
servers := a.renderServersPanel(serversW, contentH)
detail := a.renderServerDetail(rightW, detailH)
@@ -531,7 +187,6 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
total, clean, drift, failed, _ := fleetCounts(a.inv.Hosts)
innerW := max(1, w-4)
// Render badges then center the group with tighter spacing between them.
b1 := badgeHostsStyle.Render(fmt.Sprintf(" %d hosts ", total))
b2 := badgeCleanStyle.Render(Icons.Clean + fmt.Sprintf(" %d clean ", clean))
b3 := badgeDriftStyle.Render(Icons.Drift + fmt.Sprintf(" %d drift ", drift))
@@ -550,7 +205,6 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
badges,
}
// Compliance progress, latest action feedback, and recent results inline.
if a.complianceRunning {
progressLine := statusScanning.Render(Icons.Running + " " + a.statusMsg)
lines = append(lines, "", progressLine)
@@ -564,13 +218,11 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
if !a.complianceRunning && len(a.complianceJobResults) > 0 {
lines = append(lines, "")
// How many result rows fit inside the panel height.
maxRows := h - 2 - len(lines)
if maxRows < 1 {
maxRows = 1
}
// Pre-compute display labels so we can sort and align.
type resultRow struct {
label string
statusStr string
@@ -599,7 +251,6 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
}
rows = append(rows, resultRow{label: pb, statusStr: statusStr})
}
// Sort longest label first so they align visually like a table.
sort.Slice(rows, func(i, j int) bool {
return len([]rune(rows[i].label)) > len([]rune(rows[j].label))
})
@@ -608,14 +259,11 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more — see JOBS tab", len(rows)-i)))
break
}
// Pad label to max width so status values align.
label := row.label + strings.Repeat(" ", maxLabelW-len([]rune(row.label)))
lines = append(lines, " "+label+": "+row.statusStr)
}
}
// Clamp to exactly h lines so the fleet panel never overflows its allocated
// topH and pushes the server/runs panels below the footer.
allLines := strings.Split(strings.Join(lines, "\n"), "\n")
targetLines := max(1, h-2)
if len(allLines) > targetLines {
@@ -640,14 +288,11 @@ func (a *App) renderServerDetail(w, h int) string {
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
// Label:value layout — avoids wide-char alignment issues from Nerd Font icons.
const lblW = 7
lbl := func(s string) string { return subtleStyle.Render(padRight(s, lblW)) }
// Row 1: name (styled by drift state) with icon prefix.
nameVal := driftIcon(host.DriftState) + " " + driftNameStyle(host.DriftState).Render(host.Name)
// Rows 2+: two values per row using fixed-width left column.
halfW := innerW / 2
twoCol := func(l1, v1, l2, v2 string) string {
left := lbl(l1) + v1
@@ -658,7 +303,6 @@ func (a *App) renderServerDetail(w, h int) string {
var lines []string
lines = append(lines, nameVal)
// State + Group on one row.
stateVal := renderDrift(host.DriftState)
if host.Group != "" {
lines = append(lines, twoCol("State:", stateVal, "Group:", dimStyle.Render(host.Group)))
@@ -666,8 +310,7 @@ func (a *App) renderServerDetail(w, h int) string {
lines = append(lines, lbl("State:")+stateVal)
}
// Error (left) + Drift count (right) on one line.
driftCount := countHostDrift(a.hostFindings[host.Name])
driftCount := ansible.CountDrift(a.hostFindings[host.Name])
if host.LastError != "" || driftCount > 0 {
if host.LastError != "" && driftCount > 0 {
maxErrW := halfW - lblW - 1
@@ -692,8 +335,6 @@ func (a *App) renderServerDetail(w, h int) string {
}
content := header + "\n" + strings.Join(lines, "\n")
// Clamp to exactly h lines (h-2 inner + 2 border) so the panel never
// overflows its allocated height and pushes the runs panel off-screen.
contentLines := strings.Split(content, "\n")
targetLines := h - 2
if targetLines < 1 {
@@ -749,7 +390,7 @@ func (a *App) renderServersPanel(w, h int) string {
for _, hst := range group.hosts {
stateStr := padANSI(renderDrift(hst.DriftState), stateW)
driftStr := dimStyle.Render(padRight(fmt.Sprintf("%d", countHostDrift(a.hostFindings[hst.Name])), driftW))
driftStr := dimStyle.Render(padRight(fmt.Sprintf("%d", ansible.CountDrift(a.hostFindings[hst.Name])), driftW))
nameIP := " " + padRight(hst.Name, nameW) + padRight(hst.AnsibleHost, ipW)
lastCheck := ""
if hst.LastCheck != "" {
@@ -791,9 +432,6 @@ func (a *App) renderServersPanel(w, h int) string {
if innerW < 1 {
innerW = 1
}
// Pad to exactly h-2 content lines so the panel renders at height h,
// keeping alignment with the right-column panels. No Height() here —
// zone markers cause lipgloss to miscalculate height and clip rows.
contentLines := strings.Split(content, "\n")
targetLines := h - 2
for len(contentLines) < targetLines {
@@ -808,7 +446,6 @@ func (a *App) renderServersPanel(w, h int) string {
func (a *App) renderRecentRunsPanel(w, h int) string {
header := panelHeaderStyle.Render("Recent Runs")
// 1 line per run; border(2) + header block(~2) + padding(2)
maxRuns := h - 6
if maxRuns < 1 {
maxRuns = 1
@@ -841,13 +478,10 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
end = len(a.runs)
}
// Safe text width: zone markers occupy ~2 chars inside panelStyle.Width(innerW),
// so cap the plain-text line at innerW-4 to prevent lipgloss from wrapping it.
safeW := innerW - 4
if safeW < 8 {
safeW = 8
}
// seq(3) + " " + playbook — mode dropped; color already conveys status.
pbW := safeW - 5
if pbW < 4 {
pbW = 4
@@ -855,14 +489,13 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
for i := start; i < end; i++ {
run := a.runs[i]
seqNum := len(a.runs) - i // newest run = highest number
seqNum := len(a.runs) - i
pb := strings.TrimPrefix(run.Playbook, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
rawLine := fmt.Sprintf("%3d %-*s", seqNum, pbW, truncate(pb, pbW))
// Hard-cap to safeW so even measurement drift doesn't cause wrapping.
if len([]rune(rawLine)) > safeW {
rawLine = string([]rune(rawLine)[:safeW])
}
@@ -894,9 +527,6 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
lines = append(lines, subtleStyle.Render("No runs yet."))
}
// Cap and pad to exactly h-2 content lines so the panel renders at height h,
// keeping alignment with the adjacent servers panel. No Height() here —
// zone markers cause lipgloss to miscalculate line height and clip runs.
maxContentLines := h - 2
if maxContentLines < 3 {
maxContentLines = 3
@@ -952,13 +582,14 @@ func (a *App) renderFooter(w int) string {
h("S", Icons.Push, "push"),
k("q") + " " + d("quit"),
}
default: // TabServers
default:
hints = []string{
k("↑↓") + " " + d("select"),
k("/") + " " + d("filter"),
h("a", Icons.Add, "add"),
h("e", Icons.Edit, "edit"),
h("c", Icons.Scan, "scan"),
h("c", Icons.Scan, "refresh"),
h("f", Icons.Fix, "fix"),
h("u", Icons.Play, "update"),
k("Enter") + " " + d("details"),
k("q") + " " + d("quit"),
@@ -1014,8 +645,6 @@ func groupHosts(hosts []*inventory.Host) []hostGroup {
return groups
}
// Jobs / Config stub tabs
func (a *App) renderJobsTab(w, bodyH int) string {
header := panelHeaderStyle.Render("All Runs")
@@ -1140,16 +769,6 @@ func (a *App) renderConfigRepoBlock(name string, repo *config.GitRepo, status st
return lines
}
// syncActivePanelToTab keeps activePanel in sync with the sidebar tab selection.
func (a *App) syncActivePanelToTab() {
switch a.sidebarTab {
case TabServers:
a.activePanel = PanelServers
case TabJobs:
a.activePanel = PanelRuns
}
}
// truncate clips a string to maxLen runes, appending "…" if clipped.
func truncate(s string, maxLen int) string {
runes := []rune(s)
@@ -1161,16 +780,3 @@ func truncate(s string, maxLen int) string {
}
return string(runes[:maxLen-1]) + "…"
}
// shorten abbreviates a path for display — ~-relative and capped at 3 tail components.
func shorten(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
p = "~" + p[len(home):]
}
sep := string(os.PathSeparator)
parts := strings.Split(p, sep)
if len(parts) > 4 {
return "…" + sep + strings.Join(parts[len(parts)-3:], sep)
}
return p
}
+2 -2
View File
@@ -46,9 +46,9 @@ func (a *App) viewHostDetails() string {
findings := a.hostFindings[host]
summary := []string{
subtleStyle.Render("Host: ") + boldStyle.Render(host),
subtleStyle.Render("Drift: ") + runChangedStyle.Render(fmt.Sprintf("%d item(s)", countHostDrift(findings))),
subtleStyle.Render("Drift: ") + runChangedStyle.Render(fmt.Sprintf("%d item(s)", ansible.CountDrift(findings))),
}
if diag := firstDiagnostic(findings); diag != "" {
if diag := ansible.FirstDiagnostic(findings); diag != "" {
summary = append(summary, subtleStyle.Render("Issue: ")+formErrorStyle.Render(diag))
}
+2
View File
@@ -11,6 +11,7 @@ type keyMap struct {
Add key.Binding
Edit key.Binding
Check key.Binding
Fix key.Binding // apply drift findings for selected host
Update key.Binding // run apply playbook (servers tab) / apply pending updates (updates tab)
Reboot key.Binding // reboot selected host (updates tab, requires confirmation)
Enter key.Binding
@@ -35,6 +36,7 @@ var keys = keyMap{
Add: key.NewBinding(key.WithKeys("a")),
Edit: key.NewBinding(key.WithKeys("e")),
Check: key.NewBinding(key.WithKeys("c")),
Fix: key.NewBinding(key.WithKeys("f")),
Update: key.NewBinding(key.WithKeys("u")),
Reboot: key.NewBinding(key.WithKeys("b")),
Enter: key.NewBinding(key.WithKeys("enter")),
+1
View File
@@ -50,6 +50,7 @@ type complianceDoneMsg struct {
// runsLoadedMsg carries a freshly loaded run history list.
type runsLoadedMsg struct {
runs []*history.RunRecord
err error
}
// gitOpDoneMsg is delivered when a config-tab git sync or push finishes.
+3 -3
View File
@@ -195,9 +195,9 @@ func (a *App) runFindingSummary() string {
if len(findings) == 0 {
return "no structured findings"
}
drift := countStatus(findings, "changed")
failed := countStatus(findings, "failed")
unreach := countStatus(findings, "unreachable")
drift := ansible.CountByStatus(findings, "changed")
failed := ansible.CountByStatus(findings, "failed")
unreach := ansible.CountByStatus(findings, "unreachable")
hosts := map[string]bool{}
tags := map[string]bool{}
for _, f := range findings {
+48 -70
View File
@@ -29,8 +29,6 @@ var (
// 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)
@@ -80,8 +78,6 @@ var (
// Recent run list
var (
runTimeStyle = lipgloss.NewStyle().Foreground(colorSubtle)
runSelectedBg = lipgloss.NewStyle().Background(colorSelected)
runChangedStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
runCleanStyle = lipgloss.NewStyle().Foreground(colorGreen)
runFailedStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
@@ -115,22 +111,17 @@ var (
Background(lipgloss.Color("#3d0f0f")).
Foreground(lipgloss.Color("#ff5f57")).
Padding(0, 1).Bold(true)
badgeUnknownStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#262b32")).
Foreground(lipgloss.Color("#b6c2cf")).
Padding(0, 1).Bold(true)
)
// VM update status badge styles
var (
vmBadgeChecking = lipgloss.NewStyle().Foreground(colorCyan).Italic(true)
vmBadgePending = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
vmBadgeUpToDate = lipgloss.NewStyle().Foreground(colorGreen)
vmBadgeUpdating = lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
vmBadgeDone = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
vmBadgeFailed = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
vmBadgeIdle = lipgloss.NewStyle().Foreground(colorDim)
vmBadgeChecking = lipgloss.NewStyle().Foreground(colorCyan).Italic(true)
vmBadgePending = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
vmBadgeUpToDate = lipgloss.NewStyle().Foreground(colorGreen)
vmBadgeUpdating = lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
vmBadgeDone = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
vmBadgeFailed = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
vmBadgeIdle = lipgloss.NewStyle().Foreground(colorDim)
)
// Title bar
@@ -145,12 +136,6 @@ var (
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)
@@ -167,6 +152,7 @@ var dividerStyle = lipgloss.NewStyle().Foreground(colorBorder)
// Call sites use Icons.Play, Icons.Scan, etc.
// Get(name) provides dynamic lookup by lowercase field name.
type iconSet struct {
Dashboard string // nf-fa-tachometer
Servers string // nf-fa-server
Jobs string // nf-fa-list
Config string // nf-fa-cog
@@ -192,39 +178,40 @@ type iconSet struct {
Toggle string // nf-md-toggle_switch
// Distro icons (font-logos / nf-linux-* via Nerd Fonts)
DistroDebian string // nf-linux-debian 
DistroUbuntu string // nf-linux-ubuntu 
DistroFedora string // nf-linux-fedora 
DistroRedHat string // nf-linux-redhat 
DistroCentOS string // nf-linux-centos 
DistroRocky string // nf-linux-rocky_linux 
DistroAlma string // nf-linux-almalinux 
DistroArch string // nf-linux-archlinux 
DistroAlpine string // nf-linux-alpine 
DistroGeneric string // nf-linux-tux (fallback) 
DistroDebian string // nf-linux-debian 
DistroUbuntu string // nf-linux-ubuntu 
DistroFedora string // nf-linux-fedora 
DistroRedHat string // nf-linux-redhat 
DistroCentOS string // nf-linux-centos 
DistroRocky string // nf-linux-rocky_linux 
DistroAlma string // nf-linux-almalinux 
DistroArch string // nf-linux-archlinux 
DistroAlpine string // nf-linux-alpine 
DistroGeneric string // nf-linux-tux (fallback) 
}
// Get returns the icon for the given lowercase field name, or "" if not found.
func (s iconSet) Get(name string) string {
m := map[string]string{
"servers": s.Servers,
"jobs": s.Jobs,
"config": s.Config,
"updates": s.Updates,
"hamburger": s.Hamburger,
"clean": s.Clean,
"drift": s.Drift,
"failed": s.Failed,
"unreachable": s.Unreachable,
"play": s.Play,
"scan": s.Scan,
"fix": s.Fix,
"add": s.Add,
"edit": s.Edit,
"delete": s.Delete,
"ping": s.Ping,
"git": s.Git,
"running": s.Running,
"dashboard": s.Dashboard,
"servers": s.Servers,
"jobs": s.Jobs,
"config": s.Config,
"updates": s.Updates,
"hamburger": s.Hamburger,
"clean": s.Clean,
"drift": s.Drift,
"failed": s.Failed,
"unreachable": s.Unreachable,
"play": s.Play,
"scan": s.Scan,
"fix": s.Fix,
"add": s.Add,
"edit": s.Edit,
"delete": s.Delete,
"ping": s.Ping,
"git": s.Git,
"running": s.Running,
"distro_debian": s.DistroDebian,
"distro_ubuntu": s.DistroUbuntu,
"distro_fedora": s.DistroFedora,
@@ -241,6 +228,7 @@ func (s iconSet) Get(name string) string {
// Icons is the package-level singleton used by all renderers.
var Icons = iconSet{
Dashboard: "", // nf-fa-tachometer
Servers: "", // nf-fa-sitemap (distinct from hamburger bars)
Jobs: "", // nf-fa-list
Config: "", // nf-fa-cog
@@ -265,30 +253,20 @@ var Icons = iconSet{
Push: "", // nf-fa-upload
Toggle: "", // nf-fa-toggle-on
// Distro icons (font-logos / nf-linux-* via Nerd Fonts)
DistroDebian: "", // nf-linux-debian U+F306
DistroUbuntu: "", // nf-linux-ubuntu U+F31B
DistroFedora: "", // nf-linux-fedora U+F30E
DistroRedHat: "", // nf-linux-redhat U+F316
DistroCentOS: "", // nf-linux-centos U+F304
DistroRocky: "", // nf-linux-rocky U+F32B
DistroAlma: "", // nf-linux-almalinux U+F363
DistroArch: "", // nf-linux-archlinux U+F303
DistroAlpine: "", // nf-linux-alpine U+F300
DistroGeneric: "", // nf-linux-tux U+F0BC
DistroDebian: "", // nf-linux-debian U+F306
DistroUbuntu: "", // nf-linux-ubuntu U+F31B
DistroFedora: "", // nf-linux-fedora U+F30E
DistroRedHat: "", // nf-linux-redhat U+F316
DistroCentOS: "", // nf-linux-centos U+F304
DistroRocky: "", // nf-linux-rocky U+F32B
DistroAlma: "", // nf-linux-almalinux U+F363
DistroArch: "", // nf-linux-archlinux U+F303
DistroAlpine: "", // nf-linux-alpine U+F300
DistroGeneric: "", // nf-linux-tux U+F0BC
}
// ---- Render helpers ----
func renderReachable(r *bool) string {
if r == nil {
return statusUnknown.Render("unknown")
}
if *r {
return statusOnline.Render("online")
}
return statusFailed.Render("failed")
}
func renderDrift(state string) string {
switch state {
case "clean":
+1 -1347
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
package ui
import (
"ansibletui/internal/ansible"
"ansibletui/internal/history"
"ansibletui/internal/vmupdate"
)
func (a *App) applyVMHistory(records []*history.RunRecord) {
if len(records) == 0 {
return
}
for _, rec := range records {
if rec == nil || rec.Host == "" {
continue
}
state, ok := a.vmUpdater.hostStates[rec.Host]
if !ok || !state.lastChecked.IsZero() {
continue
}
hydrated, ok := a.vmStateFromHistoryRecord(rec)
if !ok {
continue
}
a.vmUpdater.hostStates[rec.Host] = hydrated
}
}
func (a *App) vmStateFromHistoryRecord(rec *history.RunRecord) (vmHostState, bool) {
if !rec.UpdateCheckedAt.IsZero() {
if rec.Status == "ok" && rec.UpdateOSFamily == "" && rec.UpdateDistro == "" {
return vmHostState{}, false
}
state := vmHostState{
updates: rec.UpdateCount,
hasDocker: rec.UpdateDocker,
rebootNeeded: rec.UpdateReboot,
osFamily: rec.UpdateOSFamily,
distro: rec.UpdateDistro,
lastChecked: rec.UpdateCheckedAt,
}
state.status = vmStatusFromHistory(rec.Status, rec.UpdateCount)
return state, true
}
if rec.Mode != "vm-check" || a.hist == nil {
return vmHostState{}, false
}
log, err := a.hist.LoadLog(rec)
if err != nil {
return vmHostState{}, false
}
parsed := ansible.ParseJSONL(log, rec.Playbook, nil)
check := vmupdate.ParseCheckResult(parsed.Rows, rec.Host)
if !check.Found {
return vmHostState{}, false
}
state := vmHostState{
updates: check.Updates,
hasDocker: check.Docker,
rebootNeeded: check.Reboot,
osFamily: check.OSFamily,
distro: check.Distro,
lastChecked: rec.EndTime,
}
if state.lastChecked.IsZero() {
state.lastChecked = rec.StartTime
}
state.status = vmStatusFromHistory(rec.Status, check.Updates)
return state, !state.lastChecked.IsZero()
}
func vmStatusFromHistory(status string, updates int) vmStatus {
switch {
case status == "failed" || status == "unreachable":
return vmStatusFailed
case updates > 0:
return vmStatusPending
default:
return vmStatusUpToDate
}
}
+678
View File
@@ -0,0 +1,678 @@
package ui
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
"ansibletui/internal/runner"
"ansibletui/internal/vmupdate"
)
// startVMPreCheck runs check_updates.yml against every host, grouped serially
// per inventory group and in parallel across groups.
func (a *App) startVMPreCheck() tea.Cmd {
return a.startVMPreCheckForHosts(a.vmUpdater.hosts, true)
}
func (a *App) startVMPreCheckForHosts(hosts []*inventory.Host, resetAll bool) tea.Cmd {
if len(hosts) == 0 {
a.statusMsg = "No host selected for update check"
return nil
}
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.vmUpdater.runCh = ch
a.vmUpdater.cancelRun = cancel
a.vmUpdater.running = true
a.vmUpdater.phase = vmPhaseChecking
a.vmUpdater.vmLogTable.Reset()
if resetAll {
for _, h := range a.vmUpdater.hosts {
a.vmUpdater.hostStates[h.Name] = vmHostState{status: vmStatusIdle}
}
}
cfg := a.cfg
hist := a.hist
targets := hosts
go func() {
defer close(ch)
type groupBatch []*inventory.Host
batchMap, batchOrder := vmupdate.GroupHosts(targets)
checkPlaybook := cfg.EffectiveCheckPlaybook()
var wg sync.WaitGroup
for _, g := range batchOrder {
batch := batchMap[g]
wg.Add(1)
go func(batch groupBatch) {
defer wg.Done()
for _, h := range batch {
select {
case <-ctx.Done():
return
default:
}
jobStart := time.Now()
ch <- vmCheckStartedMsg{host: h.Name}
argv := ansible.BuildPlaybookArgs(cfg.EffectiveInventoryPath(), checkPlaybook, h.Name, ansible.ModeApply)
lineCh := make(chan string, 256)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
var fullLog bytes.Buffer
for line := range lineCh {
fullLog.WriteString(line + "\n")
if rows := ansible.ParseJSONLLine(line, checkPlaybook, nil).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = h.Name
}
}
ch <- vmOutputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- vmOutputLineMsg{line: "[" + h.Name + "] " + line}
}
}
r := <-resCh
parsed := ansible.ParseJSONL(r.log, checkPlaybook, nil)
state := vmupdate.ParseCheckResult(parsed.Rows, h.Name)
doneMsg := vmCheckDoneMsg{
host: h.Name,
updates: state.Updates,
docker: state.Docker,
reboot: state.Reboot,
osFamily: state.OSFamily,
distro: state.Distro,
}
if r.err != nil {
doneMsg.err = r.err
} else if !state.Found {
doneMsg.err = fmt.Errorf("missing ATUI_CHECK_RESULT")
} else if r.code != 0 && doneMsg.osFamily == "" {
doneMsg.err = fmt.Errorf("exit code %d", r.code)
}
jobEnd := time.Now()
rec := &history.RunRecord{
Playbook: checkPlaybook,
Host: h.Name,
Mode: "vm-check",
StartTime: jobStart,
EndTime: jobEnd,
ExitCode: r.code,
UpdateCount: state.Updates,
UpdateCheckedAt: jobEnd,
UpdateDocker: state.Docker,
UpdateReboot: state.Reboot,
UpdateOSFamily: state.OSFamily,
UpdateDistro: state.Distro,
}
for _, recap := range parsed.Recaps {
rec.OK += recap.OK
rec.Changed += recap.Changed
rec.Failed += recap.Failed
rec.Unreachable += recap.Unreachable
rec.Skipped += recap.Skipped
}
rec.Status = vmupdate.DeriveRunStatus(rec.Unreachable, r.err, r.code, state.Found)
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, checkPlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- doneMsg
}
}(batch)
}
wg.Wait()
ch <- vmUpdateAllDoneMsg{}
}()
return waitRun(ch)
}
// startVMUpdates runs update_packages.yml on hosts that have pending updates or
// previously failed, serialised per group and parallel across groups.
func (a *App) startVMUpdates() tea.Cmd {
var targets []*inventory.Host
for _, h := range a.vmUpdater.hosts {
if s := a.vmUpdater.hostStates[h.Name]; s.status == vmStatusPending || s.status == vmStatusFailed {
targets = append(targets, h)
}
}
if len(targets) == 0 {
a.statusMsg = "All hosts are already up to date"
return nil
}
return a.startVMUpdatesForHosts(targets)
}
func (a *App) startVMUpdatesForHosts(targets []*inventory.Host) tea.Cmd {
if len(targets) == 0 {
a.statusMsg = "All selected hosts are already up to date"
return nil
}
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.vmUpdater.runCh = ch
a.vmUpdater.cancelRun = cancel
a.vmUpdater.running = true
a.vmUpdater.phase = vmPhaseUpdating
cfg := a.cfg
hist := a.hist
hosts := targets
go func() {
defer close(ch)
batchMap, batchOrder := vmupdate.GroupHosts(hosts)
updatePlaybook := cfg.EffectiveUpdatePlaybook()
checkPlaybook := cfg.EffectiveCheckPlaybook()
var wg sync.WaitGroup
for _, g := range batchOrder {
batch := batchMap[g]
wg.Add(1)
go func(batch []*inventory.Host) {
defer wg.Done()
for _, h := range batch {
select {
case <-ctx.Done():
return
default:
}
jobStart := time.Now()
ch <- vmUpdateStartedMsg{host: h.Name}
argv := ansible.BuildPlaybookArgs(cfg.EffectiveInventoryPath(), updatePlaybook, h.Name, ansible.ModeApply)
lineCh := make(chan string, 256)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, updatePlaybook, nil).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = h.Name
}
}
ch <- vmOutputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- vmOutputLineMsg{line: "[" + h.Name + "] " + line}
}
}
r := <-resCh
parsed := ansible.ParseJSONL(r.log, updatePlaybook, nil)
var recapOK, recapChanged, recapFailed, recapUnreachable, recapSkipped int
for _, recap := range parsed.Recaps {
recapOK += recap.OK
recapChanged += recap.Changed
recapFailed += recap.Failed
recapUnreachable += recap.Unreachable
recapSkipped += recap.Skipped
}
jobEnd := time.Now()
rec := &history.RunRecord{
Playbook: updatePlaybook,
Host: h.Name,
Mode: "vm-update",
OK: recapOK,
Changed: recapChanged,
Failed: recapFailed,
Unreachable: recapUnreachable,
Skipped: recapSkipped,
StartTime: jobStart,
EndTime: jobEnd,
ExitCode: r.code,
}
if recapUnreachable > 0 {
rec.Status = "unreachable"
} else if r.err != nil || r.code != 0 {
rec.Status = "failed"
} else {
rec.Status = "ok"
}
if r.code == 0 && r.err == nil {
refreshRows, refreshed := runVMRefreshCheck(ctx, cfg, checkPlaybook, h.Name, ch)
if refreshed {
state := vmupdate.ParseCheckResult(refreshRows, h.Name)
if !state.Found {
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, updatePlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmUpdateDoneMsg{
host: h.Name,
ok: true,
changed: recapChanged,
}
continue
}
rec.UpdateCount = state.Updates
rec.UpdateCheckedAt = time.Now()
rec.UpdateDocker = state.Docker
rec.UpdateReboot = state.Reboot
rec.UpdateOSFamily = state.OSFamily
rec.UpdateDistro = state.Distro
ch <- vmUpdateDoneMsg{
host: h.Name,
ok: true,
changed: recapChanged,
refreshed: true,
updates: state.Updates,
docker: state.Docker,
reboot: state.Reboot,
osFamily: state.OSFamily,
distro: state.Distro,
}
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, updatePlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
continue
}
}
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, updatePlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmUpdateDoneMsg{
host: h.Name,
ok: r.code == 0 && r.err == nil,
changed: recapChanged,
}
}
}(batch)
}
wg.Wait()
ch <- vmUpdateAllDoneMsg{exitCode: 0}
}()
return waitRun(ch)
}
// startDockerMaint runs docker_maintenance.yml on hosts where docker was detected.
func (a *App) startDockerMaint() tea.Cmd {
var targets []*inventory.Host
for _, h := range a.vmUpdater.hosts {
if s := a.vmUpdater.hostStates[h.Name]; s.hasDocker {
targets = append(targets, h)
}
}
if len(targets) == 0 {
a.statusMsg = "No hosts with Docker detected — run pre-check first"
return nil
}
return a.startDockerMaintForHosts(targets)
}
func (a *App) startDockerMaintForHosts(targets []*inventory.Host) tea.Cmd {
if len(targets) == 0 {
a.statusMsg = "No selected hosts with Docker detected — run pre-check first"
return nil
}
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.vmUpdater.runCh = ch
a.vmUpdater.cancelRun = cancel
a.vmUpdater.running = true
a.vmUpdater.phase = vmPhaseDockerMaint
cfg := a.cfg
hist := a.hist
hosts := targets
go func() {
defer close(ch)
batchMap, batchOrder := vmupdate.GroupHosts(hosts)
dockerPlaybook := cfg.EffectiveDockerPlaybook()
var wg sync.WaitGroup
for _, g := range batchOrder {
batch := batchMap[g]
wg.Add(1)
go func(batch []*inventory.Host) {
defer wg.Done()
for _, h := range batch {
select {
case <-ctx.Done():
return
default:
}
jobStart := time.Now()
ch <- vmUpdateStartedMsg{host: h.Name}
argv := ansible.BuildPlaybookArgs(cfg.EffectiveInventoryPath(), dockerPlaybook, h.Name, ansible.ModeApply)
lineCh := make(chan string, 256)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, dockerPlaybook, nil).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = h.Name
}
}
ch <- vmOutputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- vmOutputLineMsg{line: "[" + h.Name + "] " + line}
}
}
r := <-resCh
parsed := ansible.ParseJSONL(r.log, dockerPlaybook, nil)
var recapOK, recapChanged, recapFailed, recapUnreachable, recapSkipped int
for _, recap := range parsed.Recaps {
recapOK += recap.OK
recapChanged += recap.Changed
recapFailed += recap.Failed
recapUnreachable += recap.Unreachable
recapSkipped += recap.Skipped
}
jobEnd := time.Now()
rec := &history.RunRecord{
Playbook: dockerPlaybook,
Host: h.Name,
Mode: "vm-docker",
OK: recapOK,
Changed: recapChanged,
Failed: recapFailed,
Unreachable: recapUnreachable,
Skipped: recapSkipped,
StartTime: jobStart,
EndTime: jobEnd,
ExitCode: r.code,
}
if recapUnreachable > 0 {
rec.Status = "unreachable"
} else if r.err != nil || r.code != 0 {
rec.Status = "failed"
} else {
rec.Status = "ok"
}
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, dockerPlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmUpdateDoneMsg{
host: h.Name,
ok: r.code == 0 && r.err == nil,
}
}
}(batch)
}
wg.Wait()
ch <- vmUpdateAllDoneMsg{}
}()
return waitRun(ch)
}
// startVMReboot runs the reboot playbook against a single named host.
func (a *App) startVMReboot(hostName string) tea.Cmd {
var target *inventory.Host
for _, h := range a.vmUpdater.hosts {
if h.Name == hostName {
target = h
break
}
}
if target == nil {
a.statusMsg = "host not found: " + hostName
return nil
}
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.vmUpdater.runCh = ch
a.vmUpdater.cancelRun = cancel
a.vmUpdater.running = true
a.vmUpdater.phase = vmPhaseRebooting
cfg := a.cfg
hist := a.hist
h := target
go func() {
defer close(ch)
displayPlaybook := cfg.EffectiveRebootPlaybook()
checkPlaybook := cfg.EffectiveCheckPlaybook()
ch <- vmRebootStartedMsg{host: h.Name}
rebootPlaybook, resolveErr := vmupdate.ResolveRebootPlaybook(cfg)
if resolveErr != nil {
ch <- vmOutputLineMsg{rows: []ansible.LogRow{vmSyntheticLogRow(h.Name, displayPlaybook, "failed", "resolve_playbook", "Reboot", resolveErr.Error())}}
ch <- vmRebootDoneMsg{host: h.Name, ok: false}
ch <- vmUpdateAllDoneMsg{host: h.Name}
return
}
argv := ansible.BuildPlaybookArgs(cfg.EffectiveInventoryPath(), rebootPlaybook, h.Name, ansible.ModeApply)
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.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, displayPlaybook, nil).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = h.Name
}
}
ch <- vmOutputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- vmOutputLineMsg{rows: []ansible.LogRow{vmSyntheticLogRow(h.Name, displayPlaybook, "running", "output", "Reboot", line)}}
}
}
r := <-resCh
end := time.Now()
parsed := ansible.ParseJSONL(r.log, displayPlaybook, nil)
recaps := parsed.Recaps
if len(recaps) == 0 {
recaps = ansible.ParseRecap(string(r.log))
}
rec := &history.RunRecord{
Playbook: displayPlaybook,
Host: h.Name,
Mode: "vm-reboot",
StartTime: start,
EndTime: end,
ExitCode: r.code,
}
if len(recaps) > 0 {
for _, recap := range recaps {
rec.OK += recap.OK
rec.Changed += recap.Changed
rec.Failed += recap.Failed
rec.Unreachable += recap.Unreachable
rec.Skipped += recap.Skipped
}
}
if rec.Unreachable > 0 {
rec.Status = "unreachable"
} else if r.err != nil || r.code != 0 || rec.Failed > 0 {
rec.Status = "failed"
} else {
rec.Status = "ok"
}
if r.err != nil || r.code != 0 {
summary := fmt.Sprintf("ansible-playbook exited with code %d", r.code)
if r.err != nil {
summary = r.err.Error()
}
ch <- vmOutputLineMsg{rows: []ansible.LogRow{vmSyntheticLogRow(h.Name, displayPlaybook, "failed", "runner_failed", "Reboot", summary)}}
}
if r.code == 0 && r.err == nil {
refreshRows, refreshed := runVMRefreshCheck(ctx, cfg, checkPlaybook, h.Name, ch)
if refreshed {
state := vmupdate.ParseCheckResult(refreshRows, h.Name)
if !state.Found {
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, displayPlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmRebootDoneMsg{
host: h.Name,
ok: true,
}
ch <- vmUpdateAllDoneMsg{host: h.Name}
return
}
checkedAt := time.Now()
ch <- vmRebootDoneMsg{
host: h.Name,
ok: true,
refreshed: true,
updates: state.Updates,
docker: state.Docker,
reboot: state.Reboot,
osFamily: state.OSFamily,
distro: state.Distro,
}
if rec != nil {
rec.UpdateCount = state.Updates
rec.UpdateCheckedAt = checkedAt
rec.UpdateDocker = state.Docker
rec.UpdateReboot = state.Reboot
rec.UpdateOSFamily = state.OSFamily
rec.UpdateDistro = state.Distro
}
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, displayPlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmUpdateAllDoneMsg{host: h.Name}
return
}
}
_ = hist.SaveWithFindings(rec,
ansible.CompactLog(r.log, displayPlaybook, nil, cfg.LogLevel, r.code),
parsed.Findings)
ch <- vmRebootDoneMsg{
host: h.Name,
ok: r.code == 0 && r.err == nil,
}
ch <- vmUpdateAllDoneMsg{host: h.Name}
}()
return waitRun(ch)
}
func vmSyntheticLogRow(host, playbook, status, event, task, summary string) ansible.LogRow {
return ansible.LogRow{
Timestamp: time.Now().Format("15:04:05"),
Status: status,
Server: host,
Event: event,
Playbook: playbook,
Task: task,
Summary: strings.TrimSpace(summary),
Msg: strings.TrimSpace(summary),
}
}
func runVMRefreshCheck(ctx context.Context, cfg *config.Config, checkPlaybook, host string, ch chan<- tea.Msg) ([]ansible.LogRow, bool) {
argv := ansible.BuildPlaybookArgs(cfg.EffectiveInventoryPath(), checkPlaybook, host, ansible.ModeApply)
lineCh := make(chan string, 256)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansible.JSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if rows := ansible.ParseJSONLLine(line, checkPlaybook, nil).Rows; len(rows) > 0 {
for i := range rows {
if rows[i].Server == "" {
rows[i].Server = host
}
}
ch <- vmOutputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- vmOutputLineMsg{line: "[" + host + "] " + line}
}
}
r := <-resCh
if r.err != nil || r.code != 0 {
return nil, false
}
parsed := ansible.ParseJSONL(r.log, checkPlaybook, nil)
return parsed.Rows, true
}
+217 -166
View File
@@ -1,9 +1,11 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -11,6 +13,7 @@ import (
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
"ansibletui/internal/vmupdate"
)
// ---- helpers ----
@@ -98,7 +101,7 @@ func TestVMGroupHostsPartitionsByGroup(t *testing.T) {
hosts := makeHosts([][2]string{
{"node-1", "swarm"}, {"node-2", "swarm"}, {"adguard-1", "dns"}, {"kube-1", "k8s"},
})
bm, order := vmGroupHosts(hosts)
bm, order := vmupdate.GroupHosts(hosts)
if len(bm["swarm"]) != 2 {
t.Errorf("swarm group = %d hosts, want 2", len(bm["swarm"]))
@@ -114,7 +117,7 @@ func TestVMGroupHostsPartitionsByGroup(t *testing.T) {
func TestVMGroupHostsFallsBackToDefault(t *testing.T) {
hosts := makeHosts([][2]string{{"orphan", ""}})
bm, order := vmGroupHosts(hosts)
bm, order := vmupdate.GroupHosts(hosts)
if _, ok := bm["default"]; !ok {
t.Errorf("expected 'default' group for host with no group")
}
@@ -160,33 +163,39 @@ func TestVMSelectHostIgnoresNegativeIndex(t *testing.T) {
}
}
// ---- parseVMCheckResult ----
// ---- ParseCheckResult (now in vmupdate package) ----
func TestParseVMCheckResultParsesFullPayload(t *testing.T) {
rows := []ansible.LogRow{
{Server: "node-1", Msg: `ATUI_CHECK_RESULT: {"updates":5,"docker":true,"reboot":false,"os":"Debian","distro":"Ubuntu"}`},
}
r := parseVMCheckResult(rows, "node-1")
if r.updates != 5 {
t.Errorf("updates = %d, want 5", r.updates)
r := vmupdate.ParseCheckResult(rows, "node-1")
if !r.Found {
t.Fatalf("found = false, want true")
}
if !r.docker {
if r.Updates != 5 {
t.Errorf("updates = %d, want 5", r.Updates)
}
if !r.Docker {
t.Errorf("docker = false, want true")
}
if r.reboot {
if r.Reboot {
t.Errorf("reboot = true, want false")
}
if r.osFamily != "Debian" {
t.Errorf("osFamily = %q, want 'Debian'", r.osFamily)
if r.OSFamily != "Debian" {
t.Errorf("osFamily = %q, want 'Debian'", r.OSFamily)
}
if r.distro != "Ubuntu" {
t.Errorf("distro = %q, want 'Ubuntu'", r.distro)
if r.Distro != "Ubuntu" {
t.Errorf("distro = %q, want 'Ubuntu'", r.Distro)
}
}
func TestParseVMCheckResultReturnsZeroWhenNoRow(t *testing.T) {
r := parseVMCheckResult(nil, "node-1")
if r.updates != 0 || r.docker || r.reboot || r.osFamily != "" {
r := vmupdate.ParseCheckResult(nil, "node-1")
if r.Found {
t.Fatalf("found = true, want false")
}
if r.Updates != 0 || r.Docker || r.Reboot || r.OSFamily != "" {
t.Errorf("expected zero value, got %+v", r)
}
}
@@ -195,9 +204,9 @@ func TestParseVMCheckResultSkipsWrongHost(t *testing.T) {
rows := []ansible.LogRow{
{Server: "node-2", Msg: `ATUI_CHECK_RESULT: {"updates":3,"docker":false,"reboot":false,"os":"Debian","distro":"Debian"}`},
}
r := parseVMCheckResult(rows, "node-1")
if r.osFamily != "" {
t.Errorf("should not match row for different host, got osFamily=%q", r.osFamily)
r := vmupdate.ParseCheckResult(rows, "node-1")
if r.Found {
t.Errorf("should not match row for different host, got %+v", r)
}
}
@@ -206,15 +215,18 @@ func TestParseVMCheckResultHandlesEmptyServerField(t *testing.T) {
rows := []ansible.LogRow{
{Server: "", Msg: `ATUI_CHECK_RESULT: {"updates":2,"docker":false,"reboot":true,"os":"RedHat","distro":"Rocky"}`},
}
r := parseVMCheckResult(rows, "kube-1")
if r.updates != 2 {
t.Errorf("updates = %d, want 2", r.updates)
r := vmupdate.ParseCheckResult(rows, "kube-1")
if !r.Found {
t.Fatalf("found = false, want true")
}
if !r.reboot {
if r.Updates != 2 {
t.Errorf("updates = %d, want 2", r.Updates)
}
if !r.Reboot {
t.Errorf("reboot = false, want true")
}
if r.distro != "Rocky" {
t.Errorf("distro = %q, want 'Rocky'", r.distro)
if r.Distro != "Rocky" {
t.Errorf("distro = %q, want 'Rocky'", r.Distro)
}
}
@@ -222,9 +234,12 @@ func TestParseVMCheckResultFindsPrefixInLongerMessage(t *testing.T) {
rows := []ansible.LogRow{
{Server: "node-1", Msg: `ok: [node-1] => ATUI_CHECK_RESULT: {"updates":0,"docker":false,"reboot":false,"os":"Debian","distro":"Ubuntu"}`},
}
r := parseVMCheckResult(rows, "node-1")
if r.osFamily != "Debian" {
t.Errorf("failed to parse prefix in longer message, got osFamily=%q", r.osFamily)
r := vmupdate.ParseCheckResult(rows, "node-1")
if !r.Found {
t.Fatalf("found = false, want true")
}
if r.OSFamily != "Debian" {
t.Errorf("failed to parse prefix in longer message, got osFamily=%q", r.OSFamily)
}
}
@@ -299,108 +314,41 @@ func TestVMPhaseLabelCoversAllPhases(t *testing.T) {
// ---- integration: actual inventory ----
func TestIntegration_InventoryHostCount(t *testing.T) {
func TestIntegration_InventoryLoadsNonEmpty(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
// The known inventory has 11 hosts.
if len(m.hosts) != 11 {
t.Errorf("expected 11 hosts from inventory, got %d", len(m.hosts))
if len(hosts) == 0 {
t.Error("expected at least one host in inventory")
}
}
func TestIntegration_InventoryGroups(t *testing.T) {
func TestIntegration_InventorySortedByGroupThenName(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
bm, _ := vmGroupHosts(m.hosts)
wantGroups := map[string]int{
"appliance": 1,
"dns": 2,
"k8s": 3,
"media": 2,
"swarm": 3,
}
for group, count := range wantGroups {
if got := len(bm[group]); got != count {
t.Errorf("group %q: got %d hosts, want %d", group, got, count)
// Verify the sort invariant: group asc, then name asc within group.
for i := 1; i < len(m.hosts); i++ {
prev, cur := m.hosts[i-1], m.hosts[i]
if cur.Group < prev.Group {
t.Errorf("hosts[%d](%s/%s) group < hosts[%d](%s/%s) — not sorted by group",
i, cur.Group, cur.Name, i-1, prev.Group, prev.Name)
}
if cur.Group == prev.Group && cur.Name < prev.Name {
t.Errorf("within group %q: hosts[%d](%s) < hosts[%d](%s) — not sorted by name",
cur.Group, i, cur.Name, i-1, prev.Name)
}
}
}
func TestIntegration_InventoryKnownHosts(t *testing.T) {
func TestIntegration_AllHostsHaveUniqueNames(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
knownHosts := []string{
"adguard-1", "adguard-2",
"kube-1", "kube-2", "kube-3",
"node-1", "node-2", "node-3",
"plex", "pvr",
"xen-orchestra",
}
nameSet := make(map[string]bool, len(m.hosts))
seen := make(map[string]bool, len(m.hosts))
for _, h := range m.hosts {
nameSet[h.Name] = true
}
for _, name := range knownHosts {
if !nameSet[name] {
t.Errorf("expected host %q in inventory but not found", name)
}
}
}
func TestIntegration_InventoryGroupAssignments(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
hostGroup := make(map[string]string, len(m.hosts))
for _, h := range m.hosts {
hostGroup[h.Name] = h.Group
}
wantAssignments := map[string]string{
"adguard-1": "dns",
"adguard-2": "dns",
"kube-1": "k8s",
"kube-2": "k8s",
"kube-3": "k8s",
"node-1": "swarm",
"node-2": "swarm",
"node-3": "swarm",
"plex": "media",
"pvr": "media",
"xen-orchestra": "appliance",
}
for host, wantGroup := range wantAssignments {
if got := hostGroup[host]; got != wantGroup {
t.Errorf("host %q: group = %q, want %q", host, got, wantGroup)
}
}
}
func TestIntegration_InventorySortedOrder(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
// Verify hosts are sorted: group asc, then name asc within group.
// Expected order: appliance(xen-orchestra), dns(adguard-1, adguard-2),
// k8s(kube-1,2,3), media(plex,pvr), swarm(node-1,2,3)
wantOrder := []string{
"xen-orchestra",
"adguard-1", "adguard-2",
"kube-1", "kube-2", "kube-3",
"plex", "pvr",
"node-1", "node-2", "node-3",
}
for i, want := range wantOrder {
if i >= len(m.hosts) {
t.Fatalf("hosts list shorter than expected at index %d", i)
}
if m.hosts[i].Name != want {
t.Errorf("hosts[%d] = %q, want %q", i, m.hosts[i].Name, want)
if seen[h.Name] {
t.Errorf("duplicate host name: %q", h.Name)
}
seen[h.Name] = true
}
}
@@ -415,44 +363,42 @@ func TestIntegration_AllHostsInitializedIdle(t *testing.T) {
}
}
func TestIntegration_VMGroupHostsFiveGroups(t *testing.T) {
func TestIntegration_GroupMapContainsAllHosts(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
_, order := vmGroupHosts(m.hosts)
bm, _ := vmupdate.GroupHosts(m.hosts)
if len(order) != 5 {
t.Errorf("expected 5 groups, got %d: %v", len(order), order)
totalInGroups := 0
for _, groupHosts := range bm {
totalInGroups += len(groupHosts)
}
if totalInGroups != len(m.hosts) {
t.Errorf("group map has %d hosts, model has %d", totalInGroups, len(m.hosts))
}
}
func TestIntegration_SelectHostFiltersLogTable(t *testing.T) {
hosts := loadActualInventory(t)
if len(hosts) < 2 {
t.Skip("need at least 2 hosts to test filtering")
}
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(hosts)
// Seed some log rows for two hosts.
// Use the first two sorted hosts as our test subjects.
first := app.vmUpdater.hosts[0].Name
second := app.vmUpdater.hosts[1].Name
app.vmUpdater.vmLogTable.rows = []ansible.LogRow{
{Server: "kube-1", Task: "update", Summary: "ok"},
{Server: "kube-2", Task: "update", Summary: "ok"},
{Server: "node-1", Task: "update", Summary: "ok"},
{Server: first, Task: "update", Summary: "ok"},
{Server: second, Task: "update", Summary: "ok"},
}
// Find kube-1 index.
idx := -1
for i, h := range app.vmUpdater.hosts {
if h.Name == "kube-1" {
idx = i
break
}
}
if idx == -1 {
t.Fatal("kube-1 not found in sorted hosts")
}
app.vmSelectHost(idx)
app.vmSelectHost(0)
filtered := app.vmUpdater.vmLogTable.filteredRows()
for _, row := range filtered {
if row.Server != "kube-1" {
t.Errorf("filtered row has server=%q, want 'kube-1'", row.Server)
if row.Server != first {
t.Errorf("filtered row has server=%q, want %q", row.Server, first)
}
}
}
@@ -481,7 +427,8 @@ func TestIntegration_RenderVMHostTableShowsAllGroups(t *testing.T) {
app.vmUpdater = newVMUpdaterModel(hosts)
out := app.renderVMHostTable(100, 40)
for _, group := range []string{"dns", "k8s", "swarm", "media", "appliance"} {
bm, _ := vmupdate.GroupHosts(app.vmUpdater.hosts)
for group := range bm {
if !containsString(out, group) {
t.Errorf("renderVMHostTable output missing group %q", group)
}
@@ -639,7 +586,7 @@ func TestVMRebootPromptRendersWhenHostTableIsFull(t *testing.T) {
func TestResolveVMRebootPlaybookMissingDefaultFails(t *testing.T) {
cfg := &config.Config{PlaybookDir: t.TempDir()}
if _, err := resolveVMRebootPlaybook(cfg); err == nil {
if _, err := vmupdate.ResolveRebootPlaybook(cfg); err == nil {
t.Fatalf("expected missing default reboot playbook to fail")
} else if !containsString(err.Error(), "create it or set reboot_playbook") {
t.Fatalf("missing playbook error = %q, want actionable message", err.Error())
@@ -648,7 +595,7 @@ func TestResolveVMRebootPlaybookMissingDefaultFails(t *testing.T) {
func TestResolveVMRebootPlaybookCustomMissingFails(t *testing.T) {
cfg := &config.Config{PlaybookDir: t.TempDir(), RebootPlaybook: "custom-reboot.yml"}
if _, err := resolveVMRebootPlaybook(cfg); err == nil {
if _, err := vmupdate.ResolveRebootPlaybook(cfg); err == nil {
t.Fatalf("expected missing custom reboot playbook to fail")
}
}
@@ -660,7 +607,7 @@ func TestResolveVMRebootPlaybookExistingDefault(t *testing.T) {
t.Fatalf("write reboot playbook: %v", err)
}
cfg := &config.Config{PlaybookDir: dir}
playbook, err := resolveVMRebootPlaybook(cfg)
playbook, err := vmupdate.ResolveRebootPlaybook(cfg)
if err != nil {
t.Fatalf("resolveVMRebootPlaybook returned error: %v", err)
}
@@ -729,26 +676,26 @@ func TestVMPackageResultIncrementsProgress(t *testing.T) {
func TestCursorNavigationStaysInBounds(t *testing.T) {
app := newTestApp(t)
app.sidebarTab = TabVMUpdate
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{
{"a", "g1"}, {"b", "g1"}, {"c", "g2"},
}))
// Move up past the top.
// Press Up at the top — cursor must stay at 0.
app.vmUpdater.cursor = 0
if app.vmUpdater.cursor > 0 {
app.vmUpdater.cursor--
}
if app.vmUpdater.cursor < 0 {
t.Errorf("cursor went negative: %d", app.vmUpdater.cursor)
model, _ := app.Update(tea.KeyMsg{Type: tea.KeyUp})
app = model.(*App)
if app.vmUpdater.cursor != 0 {
t.Errorf("Up at top: cursor = %d, want 0", app.vmUpdater.cursor)
}
// Move down past the bottom.
app.vmUpdater.cursor = len(app.vmUpdater.hosts) - 1
if app.vmUpdater.cursor < len(app.vmUpdater.hosts)-1 {
app.vmUpdater.cursor++
}
if app.vmUpdater.cursor >= len(app.vmUpdater.hosts) {
t.Errorf("cursor exceeded host count: %d >= %d", app.vmUpdater.cursor, len(app.vmUpdater.hosts))
// Press Down from last host — cursor must stay at len-1.
last := len(app.vmUpdater.hosts) - 1
app.vmUpdater.cursor = last
model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = model.(*App)
if app.vmUpdater.cursor != last {
t.Errorf("Down at bottom: cursor = %d, want %d", app.vmUpdater.cursor, last)
}
}
@@ -826,6 +773,112 @@ func TestOpenVMUpdaterScreenAutoSelectsFirstHost(t *testing.T) {
}
}
func TestApplyVMHistoryHydratesUpdateState(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
app := newTestApp(t)
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
checkedAt := time.Now().Add(-5 * time.Minute)
app.applyVMHistory([]*history.RunRecord{{
Host: "node-1",
Mode: "vm-check",
Status: "ok",
UpdateCount: 6,
UpdateCheckedAt: checkedAt,
UpdateDocker: true,
UpdateReboot: true,
UpdateOSFamily: "Debian",
UpdateDistro: "Ubuntu",
}})
state := app.vmUpdater.hostStates["node-1"]
if state.status != vmStatusPending {
t.Fatalf("status = %v, want pending", state.status)
}
if state.updates != 6 || !state.hasDocker || !state.rebootNeeded {
t.Fatalf("hydrated state mismatch: %+v", state)
}
if !state.lastChecked.Equal(checkedAt) {
t.Fatalf("lastChecked = %v, want %v", state.lastChecked, checkedAt)
}
}
func TestApplyVMHistoryIgnoresSuccessfulUpdateStateWithoutOSMetadata(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
app := newTestApp(t)
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
app.applyVMHistory([]*history.RunRecord{{
Host: "node-1",
Mode: "vm-check",
Status: "ok",
UpdateCheckedAt: time.Now().Add(-5 * time.Minute),
}})
state := app.vmUpdater.hostStates["node-1"]
if state.status != vmStatusIdle || !state.lastChecked.IsZero() {
t.Fatalf("metadata without OS should stay unknown: %+v", state)
}
}
func TestApplyVMHistoryHydratesLegacyVMCheckLog(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
hist := history.New(t.TempDir())
rec := &history.RunRecord{
Playbook: "check_updates.yml",
Host: "node-1",
Mode: "vm-check",
Status: "ok",
StartTime: time.Now().Add(-10 * time.Minute),
EndTime: time.Now().Add(-9 * time.Minute),
}
log := []byte(`{"_event":"v2_runner_on_ok","hosts":{"node-1":{"msg":"ATUI_CHECK_RESULT: {\"updates\":3,\"docker\":true,\"reboot\":false,\"os\":\"Debian\",\"distro\":\"Ubuntu\"}"}}}`)
if err := hist.Save(rec, log); err != nil {
t.Fatal(err)
}
app := newTestApp(t)
app.hist = hist
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
app.applyVMHistory([]*history.RunRecord{rec})
state := app.vmUpdater.hostStates["node-1"]
if state.status != vmStatusPending || state.updates != 3 || !state.hasDocker {
t.Fatalf("legacy vm-check state not hydrated: %+v", state)
}
}
func TestApplyVMHistoryIgnoresVMCheckLogWithoutStructuredResult(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
hist := history.New(t.TempDir())
rec := &history.RunRecord{
Playbook: "check_updates.yml",
Host: "node-1",
Mode: "vm-check",
Status: "ok",
StartTime: time.Now().Add(-10 * time.Minute),
EndTime: time.Now().Add(-9 * time.Minute),
}
log := []byte("PLAY [Check system update status]\nTASK [Report update status]\nPLAY RECAP\nnode-1 : ok=6 changed=0 unreachable=0 failed=0 skipped=2\n")
if err := hist.Save(rec, log); err != nil {
t.Fatal(err)
}
app := newTestApp(t)
app.hist = hist
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
app.applyVMHistory([]*history.RunRecord{rec})
state := app.vmUpdater.hostStates["node-1"]
if state.status != vmStatusIdle || !state.lastChecked.IsZero() {
t.Fatalf("compact vm-check log without result should stay unknown: %+v", state)
}
}
// ---- test helpers ----
func containsString(s, sub string) bool {
@@ -874,26 +927,24 @@ func TestVMRunStatusDerivation(t *testing.T) {
cases := []struct {
name string
unreachable int
hasErr bool
err error
exitCode int
found bool
wantStatus string
}{
{"ok run", 0, false, 0, "ok"},
{"unreachable beats clean exit", 1, false, 0, "unreachable"},
{"error gives failed", 0, true, 0, "failed"},
{"nonzero exit gives failed", 0, false, 1, "failed"},
{"unreachable beats nonzero exit", 1, false, 2, "unreachable"},
{"ok run", 0, nil, 0, true, "ok"},
{"unreachable beats clean exit", 1, nil, 0, true, "unreachable"},
{"error gives failed", 0, fmt.Errorf("SSH error"), 0, true, "failed"},
{"nonzero exit gives failed", 0, nil, 1, true, "failed"},
{"missing result gives failed", 0, nil, 0, false, "failed"},
{"unreachable beats nonzero exit", 1, nil, 2, true, "unreachable"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
status := "ok"
if c.unreachable > 0 {
status = "unreachable"
} else if c.hasErr || c.exitCode != 0 {
status = "failed"
}
if status != c.wantStatus {
t.Errorf("got %q, want %q", status, c.wantStatus)
got := vmupdate.DeriveRunStatus(c.unreachable, c.err, c.exitCode, c.found)
if got != c.wantStatus {
t.Errorf("vmupdate.DeriveRunStatus(%d, %v, %d, %v) = %q, want %q",
c.unreachable, c.err, c.exitCode, c.found, got, c.wantStatus)
}
})
}
+333
View File
@@ -0,0 +1,333 @@
package ui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
"ansibletui/internal/inventory"
)
func (a *App) updateVMUpdater(msg tea.Msg) (tea.Model, tea.Cmd) {
// Host list filter is active — route all keys there.
if a.vmUpdater.hostFiltering {
return a.updateVMHostFilter(msg)
}
// Reboot confirmation is active — intercept y/n/esc.
if a.vmUpdater.rebootConfirm {
if km, ok := msg.(tea.KeyMsg); ok {
switch km.String() {
case "y", "Y":
host := a.vmUpdater.rebootTargetHost
a.vmUpdater.rebootConfirm = false
a.vmUpdater.rebootTargetHost = ""
return a, a.startVMReboot(host)
case "n", "N":
a.vmUpdater.rebootConfirm = false
a.vmUpdater.rebootTargetHost = ""
default:
if key.Matches(km, keys.Back) || key.Matches(km, keys.Quit) {
a.vmUpdater.rebootConfirm = false
a.vmUpdater.rebootTargetHost = ""
}
}
}
return a, nil
}
// Route to log table if its detail pane is open or if log pane has focus.
if a.vmUpdater.vmLogTable.detailOpen || a.vmUpdater.activePanel == vmPanelLogs {
consumed, cmd := a.vmUpdater.vmLogTable.Update(msg)
if consumed {
return a, cmd
}
}
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.Back):
if a.vmUpdater.activePanel == vmPanelLogs {
a.vmUpdater.activePanel = vmPanelHosts
return a, nil
}
if a.vmUpdater.running && a.vmUpdater.cancelRun != nil {
a.vmUpdater.cancelRun()
}
// In inline mode (home screen) switch back to Servers tab.
// In full-screen mode return to home.
if a.screen == ScreenHome {
a.sidebarTab = TabServers
a.syncActivePanelToTab()
} else {
a.screen = ScreenHome
}
return a, nil
case key.Matches(km, keys.Tab):
if a.vmUpdater.activePanel == vmPanelHosts {
a.vmUpdater.activePanel = vmPanelLogs
} else {
a.vmUpdater.activePanel = vmPanelHosts
}
return a, nil
case key.Matches(km, keys.Left):
a.vmUpdater.activePanel = vmPanelHosts
return a, nil
case key.Matches(km, keys.Right):
a.vmUpdater.activePanel = vmPanelLogs
return a, nil
case key.Matches(km, keys.Filter):
if a.vmUpdater.activePanel == vmPanelHosts {
a.vmUpdater.hostFiltering = true
a.vmUpdater.hostFilterInput.Focus()
a.vmUpdater.hostFilterInput.SetValue("")
}
return a, nil
case key.Matches(km, keys.Up):
if a.vmUpdater.cursor > 0 {
a.vmUpdater.cursor--
a.vmSelectHost(a.vmUpdater.cursor)
}
return a, nil
case key.Matches(km, keys.Down):
if a.vmUpdater.cursor < len(a.vmUpdater.hosts)-1 {
a.vmUpdater.cursor++
a.vmSelectHost(a.vmUpdater.cursor)
}
return a, nil
case key.Matches(km, keys.Enter):
a.vmSelectHost(a.vmUpdater.cursor)
return a, nil
case key.Matches(km, keys.Update):
if !a.vmUpdater.running {
if h := a.selectedVMHost(); h != nil {
state := a.vmUpdater.hostStates[h.Name]
if state.status == vmStatusPending || state.status == vmStatusFailed {
return a, a.startVMUpdatesForHosts([]*inventory.Host{h})
}
return a, a.startVMPreCheckForHosts([]*inventory.Host{h}, false)
}
}
return a, nil
case key.Matches(km, keys.Reboot):
if !a.vmUpdater.running && a.vmUpdater.activePanel == vmPanelHosts {
hosts := a.vmFilteredHosts()
if a.vmUpdater.cursor < len(hosts) {
h := hosts[a.vmUpdater.cursor]
if s := a.vmUpdater.hostStates[h.Name]; s.rebootNeeded {
a.vmUpdater.rebootConfirm = true
a.vmUpdater.rebootTargetHost = h.Name
} else {
a.statusMsg = fmt.Sprintf("%s does not require a reboot", h.Name)
}
}
}
return a, nil
case key.Matches(km, keys.Delete):
if !a.vmUpdater.running {
if h := a.selectedVMHost(); h != nil {
return a, a.startDockerMaintForHosts([]*inventory.Host{h})
}
}
return a, nil
}
// Fall through to log table for s/x filter keys when log pane doesn't have focus.
consumed, cmd := a.vmUpdater.vmLogTable.Update(msg)
if consumed {
return a, cmd
}
return a, nil
}
// updateVMHostFilter handles keystrokes while the host list filter is active.
func (a *App) updateVMHostFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
if km, ok := msg.(tea.KeyMsg); ok {
switch km.String() {
case "esc", "enter":
a.vmUpdater.hostFiltering = false
a.vmUpdater.hostFilter = a.vmUpdater.hostFilterInput.Value()
a.vmUpdater.hostFilterInput.Blur()
a.vmUpdater.cursor = 0
return a, nil
}
}
var cmd tea.Cmd
a.vmUpdater.hostFilterInput, cmd = a.vmUpdater.hostFilterInput.Update(msg)
a.vmUpdater.hostFilter = a.vmUpdater.hostFilterInput.Value()
a.vmUpdater.cursor = 0
return a, cmd
}
// vmFilteredHosts returns the host list filtered by hostFilter (case-insensitive).
func (a *App) vmFilteredHosts() []*inventory.Host {
f := strings.ToLower(a.vmUpdater.hostFilter)
if f == "" {
return a.vmUpdater.hosts
}
var out []*inventory.Host
for _, h := range a.vmUpdater.hosts {
if strings.Contains(strings.ToLower(h.Name), f) || strings.Contains(strings.ToLower(h.Group), f) {
out = append(out, h)
}
}
return out
}
// vmSelectHost sets the selected host and filters the log table to show only that host.
func (a *App) vmSelectHost(idx int) {
hosts := a.vmFilteredHosts()
if idx < 0 || idx >= len(hosts) {
return
}
name := hosts[idx].Name
a.vmUpdater.selectedHost = name
a.vmUpdater.vmLogTable.serverFilter = name
a.vmUpdater.vmLogTable.cursor = 0
a.vmUpdater.vmLogTable.offset = 0
}
func (a *App) selectedVMHost() *inventory.Host {
hosts := a.vmFilteredHosts()
if len(hosts) == 0 || a.vmUpdater.cursor >= len(hosts) {
return nil
}
return hosts[a.vmUpdater.cursor]
}
func (a *App) syncVMSelectedHost(name string) {
for i, h := range a.vmFilteredHosts() {
if h.Name == name {
a.vmUpdater.cursor = i
a.vmSelectHost(i)
return
}
}
a.vmUpdater.selectedHost = name
a.vmUpdater.vmLogTable.serverFilter = name
}
// openVMUpdaterScreen refreshes the vmUpdaterModel from the current inventory.
// Preserves existing host states and log data so switching away and back is seamless.
func (a *App) openVMUpdaterScreen() tea.Cmd {
oldStates := a.vmUpdater.hostStates
oldLogTable := a.vmUpdater.vmLogTable
oldRunCh := a.vmUpdater.runCh
oldCancel := a.vmUpdater.cancelRun
oldRunning := a.vmUpdater.running
oldPhase := a.vmUpdater.phase
a.vmUpdater = newVMUpdaterModel(a.inv.Hosts)
// Restore in-flight run state and accumulated logs.
a.vmUpdater.runCh = oldRunCh
a.vmUpdater.cancelRun = oldCancel
a.vmUpdater.running = oldRunning
a.vmUpdater.phase = oldPhase
a.vmUpdater.vmLogTable = oldLogTable
// Restore per-host states.
for name, state := range oldStates {
if _, ok := a.vmUpdater.hostStates[name]; ok {
a.vmUpdater.hostStates[name] = state
}
}
// Auto-select the first host so the right pane is populated immediately.
if len(a.vmUpdater.hosts) > 0 {
a.vmSelectHost(0)
}
// Auto-start pre-check when the VM updater is opened fresh.
if !a.vmUpdater.running && a.vmUpdater.phase == vmPhaseIdle {
return a.startVMPreCheck()
}
return nil
}
// handleVMUpdaterMouse processes mouse events on the VM updater screen.
func (a *App) handleVMUpdaterMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
inLogPane := zone.Get("vm-logpane").InBounds(msg)
switch msg.Button {
case tea.MouseButtonWheelUp:
if inLogPane {
if a.vmUpdater.vmLogTable.cursor > 0 {
a.vmUpdater.vmLogTable.cursor--
}
} else if a.vmUpdater.cursor > 0 {
a.vmUpdater.cursor--
a.vmSelectHost(a.vmUpdater.cursor)
}
case tea.MouseButtonWheelDown:
if inLogPane {
rows := a.vmUpdater.vmLogTable.filteredRows()
if a.vmUpdater.vmLogTable.cursor < len(rows)-1 {
a.vmUpdater.vmLogTable.cursor++
}
} else if a.vmUpdater.cursor < len(a.vmUpdater.hosts)-1 {
a.vmUpdater.cursor++
a.vmSelectHost(a.vmUpdater.cursor)
}
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
break
}
// Log row clicks — check per-row zones before the pane zone.
prefix := a.vmUpdater.vmLogTable.zonePrefix
if prefix != "" {
rows := a.vmUpdater.vmLogTable.filteredRows()
for i := a.vmUpdater.vmLogTable.offset; i < len(rows); i++ {
if zone.Get(prefix + strconv.Itoa(i)).InBounds(msg) {
if a.vmUpdater.activePanel == vmPanelLogs && a.vmUpdater.vmLogTable.cursor == i {
// Second click on the already-selected row: open detail.
a.vmUpdater.vmLogTable.detailOpen = true
a.vmUpdater.vmLogTable.detailVp.GotoTop()
} else {
// First click: focus pane and select the row.
a.vmUpdater.activePanel = vmPanelLogs
a.vmUpdater.vmLogTable.cursor = i
}
return a, nil
}
}
}
// Click anywhere else in the log pane focuses it.
if inLogPane {
a.vmUpdater.activePanel = vmPanelLogs
return a, nil
}
// Host row clicks.
for i := range a.vmUpdater.hosts {
if zone.Get(fmt.Sprintf("vmhost-%d", i)).InBounds(msg) {
a.vmUpdater.cursor = i
a.vmSelectHost(i)
return a, nil
}
}
default:
}
return a, nil
}
+319
View File
@@ -0,0 +1,319 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
)
func (a *App) viewVMUpdater() string {
w, h := a.width, a.height
if w == 0 {
return "Loading…"
}
title := titleStyle.Render(Icons.Servers+" ansibleTUI") + " " +
pathStyle.Render("/ VM Updates")
titleH := 1
footerH := 2 // status line (1) + footer bar (1)
borderH := 2 // panelStyle NormalBorder top + bottom
bodyH := h - titleH - footerH - borderH
if bodyH < 6 {
bodyH = 6
}
leftW := w * 50 / 100
rightW := w - leftW - 1
if rightW < 20 {
rightW = 20
}
left := a.renderVMHostTable(leftW, bodyH)
right := a.renderVMLogPane(rightW, bodyH)
body := lipgloss.JoinHorizontal(lipgloss.Top, left, dimStyle.Render("│"), right)
footer := a.renderVMFooter(w)
statusLine := ""
if a.errMsg != "" {
statusLine = runFailedStyle.Render(" " + a.errMsg)
} else if a.statusMsg != "" {
statusLine = subtleStyle.Render(" " + a.statusMsg)
}
return strings.Join([]string{title, body, statusLine, footer}, "\n")
}
func (a *App) renderVMHostTable(w, h int) string {
m := &a.vmUpdater
// Build filter header line if filtering is active.
filterLine := ""
if m.hostFiltering {
filterLine = subtleStyle.Render("/ ") + m.hostFilterInput.View()
} else if m.hostFilter != "" {
filterLine = subtleStyle.Render("filter: ") + boldStyle.Render(m.hostFilter) + dimStyle.Render(" (/ to change)")
}
header := tableHeaderStyle.Render(fmt.Sprintf(" %-23s %-10s %-12s %-7s %-6s %s",
"Host", "Group", "Status", "Upd", "Docker", "Reboot"))
lines := []string{header}
if filterLine != "" {
lines = append(lines, filterLine)
}
confirmBox := ""
confirmRows := 0
if m.rebootConfirm {
confirmBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(colorYellow).
Foreground(colorYellow).
Bold(true).
Padding(0, 1).
Render(fmt.Sprintf("Reboot %s? (y) yes (n) no", m.rebootTargetHost))
confirmRows = lipgloss.Height(confirmBox) + 1
}
maxRows := h - len(lines) - confirmRows - 1
if maxRows < 1 {
maxRows = 1
}
offset := 0
if m.cursor >= maxRows {
offset = m.cursor - maxRows + 1
}
hosts := a.vmFilteredHosts()
for i := offset; i < len(hosts) && i < offset+maxRows; i++ {
h2 := hosts[i]
state := m.hostStates[h2.Name]
statusStr := renderVMStatus(state.status)
updStr := dimStyle.Render("—")
if state.status != vmStatusIdle && state.status != vmStatusChecking {
if state.status == vmStatusUpdating && state.updatesTotal > 0 {
updStr = vmBadgeUpdating.Render(fmt.Sprintf("%d/%d", state.updatesDone, state.updatesTotal))
} else if state.updates > 0 {
updStr = vmBadgePending.Render(fmt.Sprintf("%-3d", state.updates))
} else {
updStr = dimStyle.Render("0 ")
}
} else if state.status == vmStatusChecking {
updStr = dimStyle.Render("… ")
}
dockerStr := dimStyle.Render("— ")
if state.status != vmStatusIdle && state.status != vmStatusChecking {
if state.hasDocker {
dockerStr = vmBadgeDone.Render("yes ")
} else {
dockerStr = dimStyle.Render("no ")
}
}
rebootStr := dimStyle.Render("— ")
if state.rebootNeeded {
rebootStr = vmBadgePending.Render("yes ")
} else if state.status != vmStatusIdle && state.status != vmStatusChecking {
rebootStr = dimStyle.Render("no ")
}
icon := Icons.DistroGeneric
if state.osFamily != "" {
icon = distroIcon(state.distro, state.osFamily)
}
nameW := 23
groupW := 10
name := padRight(icon+" "+h2.Name, nameW)
group := padRight(h2.Group, groupW)
raw := fmt.Sprintf(" %s %s ",
name,
group,
)
// Build the full row line with styled segments.
line := raw + padANSI(statusStr, 12) + " " +
padANSI(updStr, 7) + " " +
padANSI(dockerStr, 6) + " " +
padANSI(rebootStr, 7)
zoneID := fmt.Sprintf("vmhost-%d", i)
if i == m.cursor {
line = zone.Mark(zoneID, tableRowSelected.Render(fmt.Sprintf(" %-23s %-10s", icon+" "+h2.Name, h2.Group)+
" "+padANSI(statusStr, 12)+" "+padANSI(updStr, 7)+" "+padANSI(dockerStr, 6)+" "+padANSI(rebootStr, 7)))
} else {
line = zone.Mark(zoneID, line)
}
lines = append(lines, line)
}
if len(hosts) == 0 {
lines = append(lines, dimStyle.Render(" no hosts match the filter"))
}
// Keep the reboot prompt inside the panel instead of letting it be clipped
// below a full host table.
if confirmBox != "" {
lines = append(lines, "", confirmBox)
}
content := strings.Join(lines, "\n")
// Highlight border when this panel has focus.
style := panelStyle.Width(w - 2).Height(h)
if m.activePanel == vmPanelHosts {
style = style.BorderForeground(colorCyan)
}
return style.Render(content)
}
func (a *App) renderVMLogPane(w, h int) string {
m := &a.vmUpdater
header := ""
if m.selectedHost != "" {
header = boldStyle.Render("HOST: "+m.selectedHost) + "\n"
} else {
header = dimStyle.Render("Select a host to view its logs") + "\n"
}
logH := h - 1
if logH < 2 {
logH = 2
}
m.vmLogTable.focused = (m.activePanel == vmPanelLogs)
logView := m.vmLogTable.View(w-4, logH)
content := header + logView
// Highlight border when this panel has focus.
style := panelStyle.Width(w - 2).Height(h)
if m.activePanel == vmPanelLogs {
style = style.BorderForeground(colorCyan)
}
return zone.Mark("vm-logpane", style.Render(content))
}
func (a *App) renderVMFooter(_ int) string {
m := &a.vmUpdater
hint := func(k, desc string) string {
return hintKeyStyle.Render(k) + hintDescStyle.Render(" "+desc)
}
parts := []string{hint("↑↓←→", "navigate"), hint("/", "filter")}
if !m.running {
if m.phase != vmPhaseIdle {
parts = append(parts, hint("u", "update"))
}
parts = append(parts, hint("b", "reboot"), hint("d", "docker"))
} else {
parts = append(parts, vmBadgeUpdating.Render(vmPhaseLabel(m.phase)+"…"))
}
if m.activePanel == vmPanelLogs {
parts = append(parts, hint("Enter", "detail"), hint("s/x", "filter"), hint("Tab", "hosts"), hint("Esc", "back"))
} else {
parts = append(parts, hint("Enter", "select"), hint("Tab/→", "log pane"), hint("Esc", "back"))
}
parts = append(parts, hint("q", "quit"))
return strings.Join(parts, dimStyle.Render(" "))
}
// renderVMUpdaterInline renders a compact version of the VM updater inside the
// home screen's main content area (sidebar tab click without full-screen transition).
// The footer is handled by the outer renderFooter so it stays consistent with other tabs.
func (a *App) renderVMUpdaterInline(w, bodyH int) string {
a.vmUpdater.width = w
a.vmUpdater.height = bodyH
leftW := w * 50 / 100
rightW := w - leftW - 1
if rightW < 20 {
rightW = 20
}
// panelStyle adds a NormalBorder (1 top + 1 bottom = 2 rows). Subtract so
// the rendered panel height matches the available body height exactly.
panelH := bodyH - 2
if panelH < 4 {
panelH = 4
}
left := a.renderVMHostTable(leftW, panelH)
right := a.renderVMLogPane(rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, dimStyle.Render("│"), right)
}
// distroIcon returns the font-logos Nerd Font icon for an ansible_distribution value.
func distroIcon(distribution, osFamily string) string {
switch strings.ToLower(distribution) {
case "ubuntu":
return Icons.DistroUbuntu
case "debian":
return Icons.DistroDebian
case "fedora":
return Icons.DistroFedora
case "rocky", "rocky linux":
return Icons.DistroRocky
case "almalinux", "alma":
return Icons.DistroAlma
case "centos":
return Icons.DistroCentOS
case "redhat", "red hat enterprise linux", "rhel":
return Icons.DistroRedHat
case "archlinux", "arch":
return Icons.DistroArch
case "alpine":
return Icons.DistroAlpine
default:
// Fall back to OS family
switch strings.ToLower(osFamily) {
case "debian":
return Icons.DistroDebian
case "redhat":
return Icons.DistroRedHat
default:
return Icons.DistroGeneric
}
}
}
func renderVMStatus(s vmStatus) string {
switch s {
case vmStatusChecking:
return vmBadgeChecking.Render("checking")
case vmStatusPending:
return vmBadgePending.Render("pending")
case vmStatusUpToDate:
return vmBadgeUpToDate.Render("up to date")
case vmStatusUpdating:
return vmBadgeUpdating.Render("updating")
case vmStatusDone:
return vmBadgeDone.Render("updated")
case vmStatusFailed:
return vmBadgeFailed.Render("failed")
default:
return vmBadgeIdle.Render("idle")
}
}
func vmPhaseLabel(p vmPhase) string {
switch p {
case vmPhaseChecking:
return "checking"
case vmPhaseUpdating:
return "updating"
case vmPhaseDockerMaint:
return "docker"
case vmPhaseRebooting:
return "rebooting"
case vmPhaseDone:
return "done"
default:
return "idle"
}
}
+57
View File
@@ -0,0 +1,57 @@
package vmupdate
import (
"fmt"
"os"
"path/filepath"
"ansibletui/internal/config"
"ansibletui/internal/inventory"
)
// ResolveRebootPlaybook returns the effective reboot playbook path from cfg,
// verifying the file exists. Returns an error if it cannot be found.
func ResolveRebootPlaybook(cfg *config.Config) (string, error) {
playbook := cfg.EffectiveRebootPlaybook()
fullPath := playbook
if !filepath.IsAbs(fullPath) {
fullPath = filepath.Join(cfg.EffectivePlaybookDir(), playbook)
}
if _, err := os.Stat(fullPath); err == nil {
return playbook, nil
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("stat reboot playbook %q: %w", fullPath, err)
}
return "", fmt.Errorf("reboot playbook %q not found; create it or set reboot_playbook in ansibletui.yaml", fullPath)
}
// DeriveRunStatus maps ansible run outcomes to a canonical status string.
// Unreachable beats all other outcomes; non-zero exit or missing structured
// result is "failed"; otherwise "ok".
func DeriveRunStatus(unreachable int, err error, exitCode int, found bool) string {
if unreachable > 0 {
return "unreachable"
}
if err != nil || exitCode != 0 || !found {
return "failed"
}
return "ok"
}
// GroupHosts partitions hosts into per-group batches preserving original order within each group.
// Hosts with an empty Group are placed in the "default" bucket.
func GroupHosts(hosts []*inventory.Host) (map[string][]*inventory.Host, []string) {
batchMap := map[string][]*inventory.Host{}
var batchOrder []string
for _, h := range hosts {
g := h.Group
if g == "" {
g = "default"
}
if _, ok := batchMap[g]; !ok {
batchOrder = append(batchOrder, g)
}
batchMap[g] = append(batchMap[g], h)
}
return batchMap, batchOrder
}
+41
View File
@@ -0,0 +1,41 @@
package vmupdate_test
import (
"os"
"path/filepath"
"testing"
"ansibletui/internal/config"
"ansibletui/internal/vmupdate"
)
func TestResolveRebootPlaybookFound(t *testing.T) {
dir := t.TempDir()
pbPath := filepath.Join(dir, "reboot.yml")
if err := os.WriteFile(pbPath, []byte("---\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
PlaybookDir: dir,
RebootPlaybook: "reboot.yml",
}
got, err := vmupdate.ResolveRebootPlaybook(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "reboot.yml" {
t.Errorf("got %q, want %q", got, "reboot.yml")
}
}
func TestResolveRebootPlaybookMissing(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
PlaybookDir: dir,
RebootPlaybook: "reboot.yml",
}
_, err := vmupdate.ResolveRebootPlaybook(cfg)
if err == nil {
t.Fatal("expected error for missing playbook, got nil")
}
}
+113
View File
@@ -0,0 +1,113 @@
package vmupdate
import (
"encoding/json"
"fmt"
"strings"
"ansibletui/internal/ansible"
)
// CheckResult holds the values extracted from an ATUI_CHECK_RESULT debug line.
type CheckResult struct {
Updates int
Docker bool
Reboot bool
OSFamily string
Distro string // ansible_distribution (Ubuntu, Debian, Fedora, Rocky, CentOS, etc.)
Found bool
}
// PackageProgress holds the values extracted from an ATUI_PACKAGE_PROGRESS debug line.
type PackageProgress struct {
PackageName string
Index int
Total int
Phase string
}
// ParseCheckResult scans JSONL log rows for the ATUI_CHECK_RESULT debug message
// emitted by check_updates.yml and returns the parsed values for the given host.
func ParseCheckResult(rows []ansible.LogRow, host string) CheckResult {
const prefix = "ATUI_CHECK_RESULT: "
for _, row := range rows {
if row.Server != host && row.Server != "" {
continue
}
msg := row.Msg
if msg == "" {
msg = row.Summary
}
idx := strings.Index(msg, prefix)
if idx < 0 {
continue
}
jsonPart := strings.TrimSpace(msg[idx+len(prefix):])
var v struct {
Updates int `json:"updates"`
Docker bool `json:"docker"`
Reboot bool `json:"reboot"`
OS string `json:"os"`
Distro string `json:"distro"`
}
if err := json.Unmarshal([]byte(jsonPart), &v); err == nil {
return CheckResult{
Updates: v.Updates,
Docker: v.Docker,
Reboot: v.Reboot,
OSFamily: v.OS,
Distro: v.Distro,
Found: true,
}
}
}
return CheckResult{}
}
// ParsePackageProgress extracts an ATUI_PACKAGE_PROGRESS payload from a log row.
// Returns (progress, true) when the row contains a valid progress entry.
func ParsePackageProgress(row ansible.LogRow) (PackageProgress, bool) {
const prefix = "ATUI_PACKAGE_PROGRESS: "
msg := row.Msg
if msg == "" {
msg = row.Summary
}
idx := strings.Index(msg, prefix)
if idx < 0 {
return PackageProgress{}, false
}
jsonPart := strings.TrimSpace(msg[idx+len(prefix):])
var v struct {
Package string `json:"package"`
Index int `json:"index"`
Total int `json:"total"`
Phase string `json:"phase"`
}
if err := json.Unmarshal([]byte(jsonPart), &v); err != nil {
return PackageProgress{}, false
}
if strings.TrimSpace(v.Package) == "" || v.Total <= 0 {
return PackageProgress{}, false
}
return PackageProgress{
PackageName: strings.TrimSpace(v.Package),
Index: v.Index,
Total: v.Total,
Phase: strings.TrimSpace(v.Phase),
}, true
}
// IsPackageResultRow reports whether a log row represents a package install/skip/fail event.
func IsPackageResultRow(row ansible.LogRow) bool {
return strings.Contains(strings.ToLower(row.Task), "package") &&
strings.TrimSpace(row.Item) != "" &&
(row.Event == "runner_ok" || row.Event == "runner_failed" || row.Event == "runner_skipped")
}
// FormatPackageSummary returns a human-readable label for a package progress event.
func FormatPackageSummary(p PackageProgress) string {
if p.Index > 0 && p.Total > 0 {
return fmt.Sprintf("Installing %s (%d/%d)", p.PackageName, p.Index, p.Total)
}
return "Installing " + p.PackageName
}
+201
View File
@@ -0,0 +1,201 @@
package vmupdate_test
import (
"errors"
"testing"
"ansibletui/internal/ansible"
"ansibletui/internal/inventory"
"ansibletui/internal/vmupdate"
)
// ---- ParseCheckResult ----
func TestParseCheckResultFound(t *testing.T) {
rows := []ansible.LogRow{
{Server: "web01", Msg: `ATUI_CHECK_RESULT: {"updates":3,"docker":true,"reboot":false,"os":"Debian","distro":"Ubuntu"}`},
}
got := vmupdate.ParseCheckResult(rows, "web01")
if !got.Found {
t.Fatal("expected Found=true")
}
if got.Updates != 3 {
t.Errorf("Updates: got %d, want 3", got.Updates)
}
if !got.Docker {
t.Error("Docker: got false, want true")
}
if got.Reboot {
t.Error("Reboot: got true, want false")
}
if got.OSFamily != "Debian" {
t.Errorf("OSFamily: got %q, want %q", got.OSFamily, "Debian")
}
if got.Distro != "Ubuntu" {
t.Errorf("Distro: got %q, want %q", got.Distro, "Ubuntu")
}
}
func TestParseCheckResultWrongHost(t *testing.T) {
rows := []ansible.LogRow{
{Server: "web01", Msg: `ATUI_CHECK_RESULT: {"updates":3,"docker":true,"reboot":false,"os":"Debian","distro":"Ubuntu"}`},
}
got := vmupdate.ParseCheckResult(rows, "web02")
if got.Found {
t.Fatal("expected Found=false for wrong host")
}
}
func TestParseCheckResultEmpty(t *testing.T) {
got := vmupdate.ParseCheckResult(nil, "web01")
if got.Found {
t.Fatal("expected Found=false for empty rows")
}
}
func TestParseCheckResultMalformedJSON(t *testing.T) {
rows := []ansible.LogRow{
{Server: "web01", Msg: "ATUI_CHECK_RESULT: {bad json}"},
}
got := vmupdate.ParseCheckResult(rows, "web01")
if got.Found {
t.Fatal("expected Found=false for malformed JSON")
}
}
// ---- ParsePackageProgress ----
func TestParsePackageProgressValid(t *testing.T) {
row := ansible.LogRow{Msg: `ATUI_PACKAGE_PROGRESS: {"package":"nginx","index":2,"total":5,"phase":"installing"}`}
got, ok := vmupdate.ParsePackageProgress(row)
if !ok {
t.Fatal("expected ok=true")
}
if got.PackageName != "nginx" {
t.Errorf("PackageName: got %q, want %q", got.PackageName, "nginx")
}
if got.Index != 2 {
t.Errorf("Index: got %d, want 2", got.Index)
}
if got.Total != 5 {
t.Errorf("Total: got %d, want 5", got.Total)
}
}
func TestParsePackageProgressEmptyPackage(t *testing.T) {
row := ansible.LogRow{Msg: `ATUI_PACKAGE_PROGRESS: {"package":"","index":1,"total":3}`}
_, ok := vmupdate.ParsePackageProgress(row)
if ok {
t.Fatal("expected ok=false for empty package")
}
}
func TestParsePackageProgressZeroTotal(t *testing.T) {
row := ansible.LogRow{Msg: `ATUI_PACKAGE_PROGRESS: {"package":"foo","index":1,"total":0}`}
_, ok := vmupdate.ParsePackageProgress(row)
if ok {
t.Fatal("expected ok=false for zero total")
}
}
// ---- IsPackageResultRow ----
func TestIsPackageResultRow(t *testing.T) {
row := ansible.LogRow{Task: "Install packages", Item: "nginx", Event: "runner_ok"}
if !vmupdate.IsPackageResultRow(row) {
t.Error("expected true for valid package result row")
}
emptyItem := ansible.LogRow{Task: "Install packages", Item: "", Event: "runner_ok"}
if vmupdate.IsPackageResultRow(emptyItem) {
t.Error("expected false for empty Item")
}
}
// ---- FormatPackageSummary ----
func TestFormatPackageSummaryWithIndex(t *testing.T) {
p := vmupdate.PackageProgress{PackageName: "nginx", Index: 2, Total: 5}
got := vmupdate.FormatPackageSummary(p)
want := "Installing nginx (2/5)"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFormatPackageSummaryNoIndex(t *testing.T) {
p := vmupdate.PackageProgress{PackageName: "nginx", Index: 0, Total: 5}
got := vmupdate.FormatPackageSummary(p)
want := "Installing nginx"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// ---- GroupHosts ----
func TestGroupHosts(t *testing.T) {
hosts := []*inventory.Host{
{Name: "web01", Group: "web"},
{Name: "web02", Group: "web"},
{Name: "db01", Group: "db"},
}
batchMap, batchOrder := vmupdate.GroupHosts(hosts)
if len(batchOrder) != 2 {
t.Fatalf("expected 2 groups, got %d", len(batchOrder))
}
if batchOrder[0] != "web" {
t.Errorf("first group: got %q, want %q", batchOrder[0], "web")
}
if batchOrder[1] != "db" {
t.Errorf("second group: got %q, want %q", batchOrder[1], "db")
}
if len(batchMap["web"]) != 2 {
t.Errorf("web group: got %d hosts, want 2", len(batchMap["web"]))
}
if len(batchMap["db"]) != 1 {
t.Errorf("db group: got %d hosts, want 1", len(batchMap["db"]))
}
}
func TestGroupHostsUngrouped(t *testing.T) {
hosts := []*inventory.Host{
{Name: "lone01", Group: ""},
}
batchMap, batchOrder := vmupdate.GroupHosts(hosts)
if len(batchOrder) != 1 || batchOrder[0] != "default" {
t.Fatalf("expected [default], got %v", batchOrder)
}
if len(batchMap["default"]) != 1 {
t.Errorf("default group: got %d hosts, want 1", len(batchMap["default"]))
}
}
// ---- DeriveRunStatus ----
func TestDeriveRunStatusUnreachable(t *testing.T) {
got := vmupdate.DeriveRunStatus(1, nil, 0, true)
if got != "unreachable" {
t.Errorf("got %q, want %q", got, "unreachable")
}
}
func TestDeriveRunStatusErr(t *testing.T) {
got := vmupdate.DeriveRunStatus(0, errors.New("x"), 0, true)
if got != "failed" {
t.Errorf("got %q, want %q", got, "failed")
}
}
func TestDeriveRunStatusNotFound(t *testing.T) {
got := vmupdate.DeriveRunStatus(0, nil, 0, false)
if got != "failed" {
t.Errorf("got %q, want %q", got, "failed")
}
}
func TestDeriveRunStatusOK(t *testing.T) {
got := vmupdate.DeriveRunStatus(0, nil, 0, true)
if got != "ok" {
t.Errorf("got %q, want %q", got, "ok")
}
}