refactor to break up large files
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user