Checkpoint current ansibleTUI changes

This commit is contained in:
2026-05-27 12:46:07 -04:00
parent 9a988c5e2b
commit 827dc7a3bc
30 changed files with 5157 additions and 268 deletions
+11 -1
View File
@@ -34,6 +34,7 @@ Legacy installs may have had `~/.config/ansibletui/config.yaml` and `runs/` unde
## Configuration
See **[examples/ansibletui.example.yaml](examples/ansibletui.example.yaml)** for every supported field with comments.
For compliance scan runtime tuning and playbook design guidance, see **[docs/performance.md](docs/performance.md)**.
```bash
mkdir -p ~/.config/ansibletui
@@ -91,7 +92,8 @@ Edit `~/.config/ansibletui/ansibletui.yaml` directly for remotes, branches, and
| `d` | Delete server |
| `p` | Quick TCP reachability probe |
| `c` | Check (check + diff playbook flow) |
| `r` | Apply drifted |
| `f` | Fix drifted tagged items |
| `r` | Apply selected server manually |
| `Enter` | Open host drift details (servers panel) or run details (runs panel) |
| `Tab` | Cycle SERV / JOBS / CFG |
| `q` | Quit |
@@ -105,6 +107,14 @@ Edit `~/.config/ansibletui/ansibletui.yaml` directly for remotes, branches, and
**Check mode is a dry run.** The app passes `--check` (and optionally `--diff`) to Ansible. Structured JSONL events are rendered into concise drift findings; `changed=1` still means one task **would** change the host, not that anything was applied. The server list shows **drift** and a per-host drift count when a check finds would-be changes. Use **Apply** only when you intend to make real changes.
The **Fix** shortcut applies only structured drift findings from the latest compliance check. It limits Ansible to the affected host and the tags on the changed task, for example:
```bash
ansible-playbook -i inventory.yml site.yml --limit server-a --tags passwordless
```
For precise fixes, tag driftable tasks with stable item-level tags such as `passwordless`, `packages`, or `ssh_config`. If task tags are not available, **Fix** falls back to running the mapped compliance playbook against the drifted host.
Compliance mappings can use either simple playbook names or structured entries with tags:
```yaml
+103
View File
@@ -0,0 +1,103 @@
# Performance tuning
This project has two performance layers:
- ansibleTUI decides which compliance jobs can safely run at the same time.
- Ansible decides how much parallel SSH/module work each job can perform.
The best wins usually come from tuning both. Measure one change at a time so it
is easy to tell whether a speedup came from app scheduling, Ansible forks, SSH
pipelining, or playbook changes.
## Compliance job scheduling
Compliance mappings can define universal jobs and group-scoped jobs. Universal
check and check+diff jobs run concurrently because they are read-only drift
scans. Group-scoped jobs still serialize within their group, and apply runs stay
conservative so fixes do not roll out across overlapping targets unexpectedly.
That means a mapping like this can overlap the two universal checks:
```yaml
universal:
- site.yml
- xcp_guest_tools.yml
```
This is most useful when the playbooks spend time waiting on different remote
work. It will not make one slow playbook faster by itself. If one playbook takes
72 seconds and another takes 11 seconds, overlapping them saves at most about 11
seconds.
Avoid splitting a single playbook into several concurrent jobs that touch the
same hosts unless the tasks are known to be independent. Package managers,
service restarts, handlers, and fact gathering can contend with themselves and
make runs slower or less predictable.
Focused tags are a safer way to make recurring scans cheaper:
```yaml
universal:
- playbook: site.yml
tags: [packages, ipv6, sudo]
```
Keep broad all-host jobs only when they are already fast or when their coverage
is worth the scan time.
## Ansible configuration
For a small VM fleet, start with settings like this in the playbook repo's
`ansible.cfg`:
```ini
[defaults]
forks = 10
[connection]
pipelining = True
```
`forks` controls how many hosts Ansible works on in parallel. The default is 5,
so an 11-host inventory can leave capacity idle. Start near the fleet size, then
adjust based on controller CPU, network behavior, SSH agent behavior, and load on
the managed hosts. Remember that overlapping compliance jobs can multiply the
number of simultaneous SSH connections.
`pipelining = True` reduces connection round trips for module execution. If a
playbook starts failing around sudo or privilege escalation, check whether the
managed hosts require tty allocation for sudo. In that case, either disable
pipelining or remove the tty requirement on those hosts.
Persistent fact caching can be useful, but use it carefully when playbooks mix
`become: true` system plays and non-become user plays. Facts such as
`ansible_facts.user_dir` can differ depending on whether facts were gathered as
root or as the connecting user. If `gathering = smart` reuses root-gathered
facts in a later user play, user tasks may incorrectly target paths such as
`/root/.fonts`. Enable persistent fact caching only after the playbooks have
explicit facts or variables for user-scoped paths.
Do not default to `strategy = free` unless the playbooks are designed for hosts
to move through tasks independently. It can improve throughput for some
workloads, but it changes ordering behavior and can surprise roles that expect
lockstep execution, handlers, or cross-host coordination.
## What to measure
Look at the per-job durations in run history:
- The fleet wrapper duration shows wall-clock time for the whole scan.
- Individual job durations show which playbook is actually slow.
- A compact log can make history browsing faster, but it does not reduce Ansible
execution time.
Recent local runs showed the main `site.yml` compliance job taking roughly 72
seconds and the VM tools job roughly 11 seconds. Concurrent universal checks can
hide the short job under the long one, while higher forks and playbook-specific
task tuning are the more likely improvements for the long job.
## References
- [Ansible strategies and forks](https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_strategies.html)
- [Ansible configuration settings](https://docs.ansible.com/projects/ansible/latest/reference_appendices/config.html)
- [Ansible cache plugins](https://docs.ansible.com/projects/ansible/latest/plugins/cache.html)
+14
View File
@@ -18,6 +18,10 @@ inventory_path: ~/.ansibletui/inventory/inventory.yml
# Runtime hint: auto | wsl | native (reserved for future use)
runtime: auto
# Saved Ansible log detail: error | warn | info | debug | trace
# info keeps compact play/task/finding/recap lines; debug/trace keep raw Ansible output.
log_level: info
# Last selected playbook filename (updated by the TUI)
recent_playbook: ""
@@ -76,6 +80,16 @@ inventory_git:
sync_on_startup: true
# --- VM Updater tab playbook paths (optional) ---
#
# Paths are relative to playbook_dir. Defaults apply when omitted.
# Copy the template playbooks from examples/playbooks/ to your playbook_dir.
#
# check_playbook: check_updates.yml # pre-check: counts pending updates, detects docker/reboot
# update_playbook: update_packages.yml # runs apt/dnf upgrade per host
# docker_playbook: docker_maintenance.yml # docker system prune (skips hosts without docker)
# reboot_playbook: reboot.yml # graceful reboot (requires confirmation in UI)
# --- Minimal setup (no git) ---
#
# playbook_dir: ~/homelab/ansible
+2
View File
@@ -5,6 +5,8 @@ universal:
- site.yml
# Optional structured entries run the same playbook for a focused tag set.
# This gives faster per-area drift feedback without splitting playbooks.
# Drift-only fixes use the tags on the changed task, so tag fixable tasks
# with stable item-level tags such as passwordless, packages, or ssh_config.
- playbook: site.yml
tags: [sudo]
- playbook: site.yml
+65
View File
@@ -0,0 +1,65 @@
---
# check_updates.yml — Pre-check playbook for the ansibleTUI VM Updater tab.
#
# Detects pending package updates, docker presence, and reboot requirements.
# Outputs a structured debug line that ansibleTUI parses to populate the host table.
#
# Usage: ansible-playbook check_updates.yml -l <host> -i inventory.yml
- name: Check system update status
hosts: all
gather_facts: yes
vars:
_update_count: 0
_docker_installed: false
_reboot_needed: false
tasks:
- name: Count pending updates (Debian/Ubuntu)
shell: apt-get -s upgrade 2>/dev/null | awk '/^Inst / { count++ } END { print count + 0 }'
when: ansible_facts['os_family'] == "Debian"
register: _debian_updates
changed_when: false
- name: Count pending updates (RedHat/Rocky/CentOS)
shell: dnf check-update --quiet 2>/dev/null | awk 'NF >= 3 && $1 ~ /^[[:alnum:]_+.-]+\.[[:alnum:]_]+$/ { count++ } END { print count + 0 }'
when: ansible_facts['os_family'] == "RedHat"
register: _rhel_updates
changed_when: false
failed_when: false
- name: Check docker is installed
shell: which docker
register: _docker_check
changed_when: false
failed_when: false
- name: Check reboot required (Debian/Ubuntu)
stat:
path: /var/run/reboot-required
when: ansible_facts['os_family'] == "Debian"
register: _reboot_deb
- name: Check reboot required (RedHat/Rocky/CentOS)
shell: needs-restarting -r > /dev/null 2>&1; echo $?
when: ansible_facts['os_family'] == "RedHat"
register: _reboot_rhl
changed_when: false
failed_when: false
- name: Set update count fact
set_fact:
_update_count: >-
{{ (_debian_updates.stdout | default('0')) | int
if ansible_facts['os_family'] == "Debian"
else (_rhel_updates.stdout | default('0')) | int }}
_docker_installed: "{{ _docker_check.rc == 0 }}"
_reboot_needed: >-
{{ (_reboot_deb.stat.exists | default(false))
if ansible_facts['os_family'] == "Debian"
else ((_reboot_rhl.stdout | default('0')) | int != 0) }}
- name: Report update status
debug:
msg: "ATUI_CHECK_RESULT: {{ {'updates': _update_count | int, 'docker': _docker_installed | bool, 'reboot': _reboot_needed | bool, 'os': ansible_facts['os_family'], 'distro': ansible_facts['distribution']} | to_json }}"
+24
View File
@@ -0,0 +1,24 @@
---
# docker_maintenance.yml — Docker cleanup playbook for the ansibleTUI VM Updater tab.
#
# Checks whether docker is installed before running. Hosts without docker are skipped
# automatically (no task failure). ansibleTUI only runs this on hosts where the
# pre-check detected docker, but the playbook defends itself regardless.
#
# Usage: ansible-playbook docker_maintenance.yml -l <host> -i inventory.yml
- name: Docker maintenance
hosts: all
gather_facts: no
become: yes
tasks:
- name: Check if docker is installed
shell: which docker
register: _docker_check
changed_when: false
failed_when: false
- name: Remove stopped containers, unused images, and unused volumes
shell: docker system prune -af --volumes
when: _docker_check.rc == 0
+22
View File
@@ -0,0 +1,22 @@
---
# reboot.yml — Reboot playbook for the ansibleTUI VM Updater tab.
#
# Reboots the target host and waits for it to come back online before
# returning control. ansibleTUI calls this with -l <host> for a single
# host after the user confirms the reboot prompt.
#
# Usage: ansible-playbook reboot.yml -l <host> -i inventory.yml
- name: Reboot host
hosts: all
gather_facts: no
become: yes
tasks:
- name: Reboot
reboot:
reboot_timeout: 300
connect_timeout: 10
pre_reboot_delay: 5
post_reboot_delay: 15
test_command: uptime
@@ -0,0 +1,16 @@
---
- name: Report package update progress
debug:
msg: "ATUI_PACKAGE_PROGRESS: {{ {'package': update_package, 'index': (update_index | int) + 1, 'total': update_total | int, 'phase': 'installing'} | to_json }}"
- name: Update package (Debian/Ubuntu)
apt:
name: "{{ update_package }}"
state: latest
when: ansible_facts['os_family'] == "Debian"
- name: Update package (RedHat/Rocky/CentOS)
dnf:
name: "{{ update_package }}"
state: latest
when: ansible_facts['os_family'] == "RedHat"
+57
View File
@@ -0,0 +1,57 @@
---
# update_packages.yml — OS package update playbook for the ansibleTUI VM Updater tab.
#
# Runs the appropriate package manager upgrade for Debian/Ubuntu and RedHat-family hosts.
# ansibleTUI calls this with -l <host> for each host, controlling concurrency itself
# (1 host per group in parallel, serial within a group).
#
# Usage: ansible-playbook update_packages.yml -l <host> -i inventory.yml
- name: Update system packages
hosts: all
gather_facts: yes
gather_subset:
- min
become: yes
tasks:
- name: Update package cache (Debian/Ubuntu)
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_facts['os_family'] == "Debian"
- name: Count upgradeable packages (Debian/Ubuntu)
shell: apt-get -s upgrade 2>/dev/null | awk '/^Inst / { print $2 }'
when: ansible_facts['os_family'] == "Debian"
register: _debian_update_packages
changed_when: false
- name: Count upgradeable packages (RedHat/Rocky/CentOS)
shell: dnf check-update --quiet 2>/dev/null | awk 'NF >= 3 && $1 ~ /^[[:alnum:]_+.-]+\.[[:alnum:]_]+$/ { print $1 }'
when: ansible_facts['os_family'] == "RedHat"
register: _rhel_update_packages
changed_when: false
failed_when: false
- name: Update packages individually (Debian/Ubuntu)
include_tasks: tasks/update_package_item.yml
loop: "{{ _debian_update_packages.stdout_lines | default([]) }}"
loop_control:
loop_var: update_package
index_var: update_index
label: "{{ update_package }}"
vars:
update_total: "{{ _debian_update_packages.stdout_lines | default([]) | length }}"
when: ansible_facts['os_family'] == "Debian"
- name: Update packages individually (RedHat/Rocky/CentOS)
include_tasks: tasks/update_package_item.yml
loop: "{{ _rhel_update_packages.stdout_lines | default([]) }}"
loop_control:
loop_var: update_package
index_var: update_index
label: "{{ update_package }}"
vars:
update_total: "{{ _rhel_update_packages.stdout_lines | default([]) | length }}"
when: ansible_facts['os_family'] == "RedHat"
+394 -18
View File
@@ -2,17 +2,21 @@ package ansible
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// Finding is one actionable event from an Ansible JSONL stream.
type Finding struct {
Host string `json:"host"`
Playbook string `json:"playbook,omitempty"`
Tags []string `json:"tags,omitempty"`
Tags []string `json:"tags,omitempty"` // display tags: run tags when present, otherwise task tags
RunTags []string `json:"run_tags,omitempty"` // tags passed to ansible-playbook for this run
TaskTags []string `json:"task_tags,omitempty"` // tags declared on the task that produced the finding
Play string `json:"play,omitempty"`
Task string `json:"task,omitempty"`
TaskPath string `json:"task_path,omitempty"`
@@ -24,30 +28,70 @@ type Finding struct {
Raw json.RawMessage `json:"raw,omitempty"`
}
// LogRow is one displayable row from an Ansible JSONL stream.
type LogRow struct {
Timestamp string `json:"timestamp,omitempty"`
Status string `json:"status,omitempty"`
Server string `json:"server,omitempty"`
Event string `json:"event,omitempty"`
Playbook string `json:"playbook,omitempty"`
Play string `json:"play,omitempty"`
Task string `json:"task,omitempty"`
TaskPath string `json:"task_path,omitempty"`
Path string `json:"path,omitempty"`
Summary string `json:"summary,omitempty"`
Changed *bool `json:"changed,omitempty"`
Msg string `json:"msg,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Item string `json:"item,omitempty"`
Action string `json:"action,omitempty"`
Recap *Recap `json:"recap,omitempty"`
Diff []LogDiff `json:"diff,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
// LogDiff contains long-form before/after content for a log row detail view.
type LogDiff struct {
BeforeHeader string `json:"before_header,omitempty"`
AfterHeader string `json:"after_header,omitempty"`
Before string `json:"before,omitempty"`
After string `json:"after,omitempty"`
}
// JSONLResult is the structured view derived from Ansible JSONL output.
type JSONLResult struct {
Recaps []Recap
Findings []Finding
Rows []LogRow
Lines []string
}
type jsonlEvent struct {
Event string `json:"_event"`
Play jsonlPlay `json:"play"`
Task jsonlTask `json:"task"`
Hosts map[string]json.RawMessage `json:"hosts"`
Stats map[string]jsonlStats `json:"stats"`
Event string `json:"_event"`
Timestamp string `json:"_timestamp"`
Play jsonlPlay `json:"play"`
Task jsonlTask `json:"task"`
Hosts map[string]json.RawMessage `json:"hosts"`
Stats map[string]jsonlStats `json:"stats"`
}
type jsonlPlay struct {
Name string `json:"name"`
Path string `json:"path"`
Name string `json:"name"`
Path string `json:"path"`
Duration jsonlDuration `json:"duration"`
}
type jsonlTask struct {
Name string `json:"name"`
Path string `json:"path"`
Tags []string `json:"tags"`
Name string `json:"name"`
Path string `json:"path"`
Tags []string `json:"tags"`
Duration jsonlDuration `json:"duration"`
}
type jsonlDuration struct {
Start string `json:"start"`
End string `json:"end"`
}
type jsonlStats struct {
@@ -99,6 +143,9 @@ func ParseJSONL(output []byte, playbook string, tags []string) JSONLResult {
if len(eventResult.Findings) > 0 {
result.Findings = append(result.Findings, eventResult.Findings...)
}
if len(eventResult.Rows) > 0 {
result.Rows = append(result.Rows, eventResult.Rows...)
}
if len(eventResult.Lines) > 0 {
result.Lines = append(result.Lines, eventResult.Lines...)
}
@@ -113,20 +160,58 @@ func ParseJSONLLine(line, playbook string, tags []string) JSONLResult {
return JSONLResult{}
}
switch event.Event {
case "v2_playbook_on_play_start":
return JSONLResult{Rows: []LogRow{{
Timestamp: displayTimestamp(event.Timestamp),
Status: "start",
Event: "play_start",
Playbook: playbook,
Play: fallback(event.Play.Name, playbook),
Task: fallback(event.Play.Name, playbook),
Path: event.Play.Path,
Raw: json.RawMessage(line),
}}}
case "v2_playbook_on_task_start", "v2_playbook_on_handler_task_start":
return JSONLResult{Rows: []LogRow{{
Timestamp: displayTimestamp(event.Timestamp),
Status: "start",
Event: "task_start",
Playbook: playbook,
Play: event.Play.Name,
Task: fallback(event.Task.Name, event.Play.Name),
TaskPath: event.Task.Path,
Path: event.Task.Path,
Summary: compactDuration(event.Task.Duration),
Raw: json.RawMessage(line),
}}}
case "v2_playbook_on_stats":
recaps := make([]Recap, 0, len(event.Stats))
rows := make([]LogRow, 0, len(event.Stats))
for host, stat := range event.Stats {
recaps = append(recaps, Recap{
recap := Recap{
Host: host,
OK: stat.OK,
Changed: stat.Changed,
Failed: stat.Failures,
Unreachable: stat.Unreachable,
Skipped: stat.Skipped,
}
recaps = append(recaps, recap)
rows = append(rows, LogRow{
Timestamp: displayTimestamp(event.Timestamp),
Status: recapStatus(recap),
Server: host,
Event: "stats",
Playbook: playbook,
Task: "PLAY RECAP",
Summary: fmt.Sprintf("ok=%d changed=%d unreachable=%d failed=%d skipped=%d", recap.OK, recap.Changed, recap.Unreachable, recap.Failed, recap.Skipped),
Recap: &recap,
Raw: json.RawMessage(line),
})
}
sort.Slice(recaps, func(i, j int) bool { return recaps[i].Host < recaps[j].Host })
return JSONLResult{Recaps: recaps}
sort.Slice(rows, func(i, j int) bool { return rows[i].Server < rows[j].Server })
return JSONLResult{Recaps: recaps, Rows: rows}
case "v2_runner_on_ok", "v2_runner_on_failed", "v2_runner_on_unreachable", "v2_runner_on_skipped":
return parseRunnerEvent(event, line, playbook, tags)
default:
@@ -147,17 +232,45 @@ func parseRunnerEvent(event jsonlEvent, line, playbook string, tags []string) JS
var hr hostResult
hr.Raw = raw
_ = json.Unmarshal(raw, &hr)
rowStatus := status
findingStatus := status
if status == "ok" && hr.Changed {
rowStatus = "changed"
findingStatus = "changed"
}
row := LogRow{
Timestamp: displayTimestamp(event.Timestamp),
Status: rowStatus,
Server: host,
Event: runnerEventName(event.Event),
Playbook: playbook,
Play: event.Play.Name,
Task: fallback(event.Task.Name, event.Play.Name),
TaskPath: event.Task.Path,
Path: resultPath(hr),
Summary: resultSummary(rowStatus, hr),
Changed: boolPtr(hr.Changed),
Msg: hr.Msg,
Stdout: hr.Stdout,
Stderr: hr.Stderr,
Item: stringifyItem(hr.Item),
Action: hr.Action,
Diff: logDiffs(hr.Diff),
Raw: json.RawMessage(line),
}
if row.Summary == "" {
row.Summary = rowStatus
}
out.Rows = append(out.Rows, row)
if status == "ok" && !hr.Changed {
continue
}
findingStatus := status
if findingStatus == "ok" {
findingStatus = "changed"
}
f := Finding{
Host: host,
Playbook: playbook,
Tags: copyStrings(tags),
RunTags: copyStrings(tags),
TaskTags: copyStrings(event.Task.Tags),
Play: event.Play.Name,
Task: event.Task.Name,
TaskPath: event.Task.Path,
@@ -169,7 +282,7 @@ func parseRunnerEvent(event jsonlEvent, line, playbook string, tags []string) JS
Raw: json.RawMessage(line),
}
if len(f.Tags) == 0 {
f.Tags = copyStrings(event.Task.Tags)
f.Tags = copyStrings(f.TaskTags)
}
out.Findings = append(out.Findings, f)
out.Lines = append(out.Lines, CompactFindingLine(f))
@@ -190,6 +303,65 @@ func eventStatus(event string) string {
}
}
func runnerEventName(event string) string {
switch event {
case "v2_runner_on_ok":
return "runner_ok"
case "v2_runner_on_failed":
return "runner_failed"
case "v2_runner_on_unreachable":
return "runner_unreachable"
case "v2_runner_on_skipped":
return "runner_skipped"
default:
return event
}
}
func recapStatus(r Recap) string {
switch {
case r.Failed > 0:
return "failed"
case r.Unreachable > 0:
return "unreachable"
case r.Changed > 0:
return "changed"
default:
return "ok"
}
}
func displayTimestamp(ts string) string {
if ts == "" {
return ""
}
t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return ts
}
return t.Format("15:04:05")
}
func boolPtr(v bool) *bool {
return &v
}
func logDiffs(in []diffEntry) []LogDiff {
if len(in) == 0 {
return nil
}
out := make([]LogDiff, 0, len(in))
for _, d := range in {
out = append(out, LogDiff{
BeforeHeader: d.BeforeHeader,
AfterHeader: d.AfterHeader,
Before: d.Before,
After: d.After,
})
}
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)
@@ -199,6 +371,196 @@ func CompactJSONLLine(line, playbook string, tags []string) (string, bool) {
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 {
lvl := parseLogLevel(level)
if lvl >= logLevelDebug {
return output
}
var out bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(output))
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "{") {
for _, compact := range compactJSONLLogLine(line, playbook, tags, lvl) {
out.WriteString(compact)
out.WriteByte('\n')
}
continue
}
if keepPlainLogLine(line, lvl) {
out.WriteString(line)
out.WriteByte('\n')
}
}
if exitCode != 0 {
out.WriteString(fmt.Sprintf("ERROR exit_code=%d\n", exitCode))
}
if out.Len() == 0 && len(output) > 0 && lvl >= logLevelInfo {
out.WriteString("No compact log events matched this run.\n")
}
return out.Bytes()
}
type compactLogLevel int
const (
logLevelError compactLogLevel = iota
logLevelWarn
logLevelInfo
logLevelDebug
)
func parseLogLevel(level string) compactLogLevel {
switch strings.ToLower(strings.TrimSpace(level)) {
case "error":
return logLevelError
case "warn":
return logLevelWarn
case "debug", "trace":
return logLevelDebug
default:
return logLevelInfo
}
}
func compactJSONLLogLine(line, playbook string, tags []string, level compactLogLevel) []string {
var event jsonlEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
if keepPlainLogLine(line, level) {
return []string{line}
}
return nil
}
switch event.Event {
case "v2_playbook_on_play_start":
if level >= logLevelInfo {
return []string{fmt.Sprintf("PLAY [%s]%s", fallback(event.Play.Name, playbook), pathSuffix(event.Play.Path))}
}
case "v2_playbook_on_task_start", "v2_playbook_on_handler_task_start":
if level >= logLevelInfo {
return []string{fmt.Sprintf("TASK [%s]%s", fallback(event.Task.Name, event.Play.Name), pathSuffix(event.Task.Path))}
}
case "v2_runner_on_ok", "v2_runner_on_failed", "v2_runner_on_unreachable":
return compactRunnerLogLines(event, line, playbook, tags, level)
case "v2_playbook_on_stats":
return compactStatsLogLines(event, level)
}
return nil
}
func compactRunnerLogLines(event jsonlEvent, line, playbook string, tags []string, level compactLogLevel) []string {
status := eventStatus(event.Event)
result := parseRunnerEvent(event, line, playbook, tags)
if status == "ok" && len(result.Findings) == 0 {
return nil
}
if level == logLevelError && status != "failed" && status != "unreachable" {
return nil
}
lines := make([]string, 0, len(result.Findings))
for _, finding := range result.Findings {
if level == logLevelWarn && finding.Status != "failed" && finding.Status != "unreachable" {
continue
}
line := CompactFindingLine(finding)
if duration := compactDuration(event.Task.Duration); duration != "" {
line += " (" + duration + ")"
}
lines = append(lines, line)
}
return lines
}
func compactStatsLogLines(event jsonlEvent, level compactLogLevel) []string {
if len(event.Stats) == 0 {
return nil
}
hosts := make([]string, 0, len(event.Stats))
for host := range event.Stats {
hosts = append(hosts, host)
}
sort.Strings(hosts)
lines := []string{"PLAY RECAP"}
for _, host := range hosts {
stat := event.Stats[host]
if level == logLevelError && stat.Failures == 0 && stat.Unreachable == 0 {
continue
}
if level == logLevelWarn && stat.Failures == 0 && stat.Unreachable == 0 {
continue
}
lines = append(lines, fmt.Sprintf("%s : ok=%d changed=%d unreachable=%d failed=%d skipped=%d",
host, stat.OK, stat.Changed, stat.Unreachable, stat.Failures, stat.Skipped))
}
if len(lines) == 1 && level >= logLevelInfo {
for _, host := range hosts {
stat := event.Stats[host]
lines = append(lines, fmt.Sprintf("%s : ok=%d changed=%d unreachable=%d failed=%d skipped=%d",
host, stat.OK, stat.Changed, stat.Unreachable, stat.Failures, stat.Skipped))
}
}
if len(lines) == 1 {
return nil
}
return lines
}
func keepPlainLogLine(line string, level compactLogLevel) bool {
if level >= logLevelDebug {
return true
}
lower := strings.ToLower(line)
hasProblem := strings.Contains(lower, "error") ||
strings.Contains(lower, "failed") ||
strings.Contains(lower, "failure") ||
strings.Contains(lower, "fatal") ||
strings.Contains(lower, "unreachable") ||
strings.Contains(lower, "timeout") ||
strings.Contains(lower, "timed out") ||
strings.Contains(lower, "permission denied") ||
strings.Contains(lower, "authentication")
return hasProblem
}
func compactDuration(d jsonlDuration) string {
if d.Start == "" || d.End == "" {
return ""
}
start, err := time.Parse(time.RFC3339Nano, d.Start)
if err != nil {
return ""
}
end, err := time.Parse(time.RFC3339Nano, d.End)
if err != nil {
return ""
}
return end.Sub(start).Round(time.Millisecond).String()
}
func pathSuffix(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
return " " + path
}
func fallback(primary, secondary string) string {
if strings.TrimSpace(primary) != "" {
return primary
}
return secondary
}
// CompactFindingLine renders one finding for live output or run details.
func CompactFindingLine(f Finding) string {
task := f.Task
@@ -253,11 +615,25 @@ func resultSummary(status string, hr hostResult) string {
if status == "unreachable" {
return concise(hr.Msg, 240)
}
item := stringifyItem(hr.Item)
if strings.TrimSpace(item) != "" && status == "changed" {
return concise("Updated "+item, 160)
}
for _, s := range []string{hr.Msg, hr.Stdout, hr.Stderr} {
if strings.TrimSpace(s) != "" {
return concise(s, 160)
}
}
if strings.TrimSpace(item) != "" {
switch status {
case "failed":
return concise("Failed "+item, 160)
case "skipped":
return concise("Skipped "+item, 160)
default:
return concise("OK "+item, 160)
}
}
if status == "changed" {
return "would change"
}
+67 -1
View File
@@ -1,6 +1,7 @@
package ansible
import (
"reflect"
"strings"
"testing"
)
@@ -21,6 +22,27 @@ func TestParseJSONLChangedWithDiffAndTags(t *testing.T) {
if !strings.Contains(got.Lines[0], "Deploy config") {
t.Fatalf("compact line missing task: %q", got.Lines[0])
}
if len(got.Rows) != 1 || got.Rows[0].Status != "changed" || got.Rows[0].Server != "web01" || got.Rows[0].Path != "/etc/example.conf" {
t.Fatalf("unexpected log rows: %#v", got.Rows)
}
}
func TestParseJSONLPreservesRunTagsAndTaskTags(t *testing.T) {
line := `{"_event":"v2_runner_on_ok","hosts":{"web01":{"changed":true}},"task":{"name":"Configure passwordless sudo","path":"roles/sudo/tasks/main.yml:9","tags":["sudo","passwordless"]}}`
got := ParseJSONLLine(line, "site.yml", []string{"sudo"})
if len(got.Findings) != 1 {
t.Fatalf("findings = %d", len(got.Findings))
}
f := got.Findings[0]
if !reflect.DeepEqual(f.RunTags, []string{"sudo"}) {
t.Fatalf("run tags = %#v", f.RunTags)
}
if !reflect.DeepEqual(f.TaskTags, []string{"passwordless", "sudo"}) {
t.Fatalf("task tags = %#v", f.TaskTags)
}
if !reflect.DeepEqual(f.Tags, []string{"sudo"}) {
t.Fatalf("display tags = %#v", f.Tags)
}
}
func TestParseJSONLUnreachableDiagnosticRemoteTmp(t *testing.T) {
@@ -59,6 +81,21 @@ func TestParseJSONLSkippedLoopItem(t *testing.T) {
}
}
func TestParseJSONLPackageLoopItemSummary(t *testing.T) {
line := `{"_event":"v2_runner_on_ok","_timestamp":"2026-05-25T04:16:02Z","hosts":{"node-3":{"changed":true,"item":"kernel-core.x86_64"}},"task":{"name":"Update package (RedHat/Rocky/CentOS)"}}`
got := ParseJSONLLine(line, "update_packages.yml", nil)
if len(got.Rows) != 1 {
t.Fatalf("rows = %d", len(got.Rows))
}
row := got.Rows[0]
if row.Item != "kernel-core.x86_64" {
t.Fatalf("item = %q, want kernel-core.x86_64", row.Item)
}
if row.Summary != "Updated kernel-core.x86_64" {
t.Fatalf("summary = %q", row.Summary)
}
}
func TestParseJSONLStats(t *testing.T) {
line := `{"_event":"v2_playbook_on_stats","stats":{"web01":{"changed":2,"failures":1,"ok":4,"skipped":3,"unreachable":0},"web02":{"changed":0,"failures":0,"ok":5,"skipped":1,"unreachable":1}}}`
got := ParseJSONLLine(line, "site.yml", nil)
@@ -71,11 +108,40 @@ func TestParseJSONLStats(t *testing.T) {
if got.Recaps[1].Host != "web02" || got.Recaps[1].Unreachable != 1 {
t.Fatalf("second recap = %+v", got.Recaps[1])
}
if len(got.Rows) != 2 || got.Rows[0].Status != "failed" || got.Rows[1].Status != "unreachable" {
t.Fatalf("stats rows = %#v", got.Rows)
}
}
func TestParseJSONLIgnoresPlainText(t *testing.T) {
got := ParseJSONL([]byte("PLAY RECAP\nnot json\n"), "site.yml", nil)
if len(got.Findings) != 0 || len(got.Recaps) != 0 {
if len(got.Findings) != 0 || len(got.Recaps) != 0 || len(got.Rows) != 0 {
t.Fatalf("unexpected parse result: %+v", got)
}
}
func TestParseJSONLRowsForAllParsedEventTypes(t *testing.T) {
raw := strings.Join([]string{
`{"_event":"v2_playbook_on_play_start","_timestamp":"2026-05-25T04:16:00.692685Z","play":{"name":"Configure fleet","path":"site.yml:1"}}`,
`{"_event":"v2_playbook_on_task_start","_timestamp":"2026-05-25T04:16:00.698934Z","task":{"name":"Gathering Facts","path":"site.yml:2"}}`,
`{"_event":"v2_runner_on_ok","_timestamp":"2026-05-25T04:16:01Z","hosts":{"web01":{"changed":false,"msg":"already current"}},"task":{"name":"Check package"}}`,
`{"_event":"v2_runner_on_ok","_timestamp":"2026-05-25T04:16:02Z","hosts":{"web01":{"changed":true,"msg":"updated"}},"task":{"name":"Install package"}}`,
`{"_event":"v2_runner_on_failed","_timestamp":"2026-05-25T04:16:03Z","hosts":{"web02":{"changed":false,"msg":"permission denied","stderr":"fatal details"}},"task":{"name":"Restart service"}}`,
`{"_event":"v2_runner_on_unreachable","_timestamp":"2026-05-25T04:16:04Z","hosts":{"web03":{"msg":"ssh: connect to host web03 port 22: Operation timed out"}},"task":{"name":"Gathering Facts"}}`,
`{"_event":"v2_runner_on_skipped","_timestamp":"2026-05-25T04:16:05Z","hosts":{"web04":{"changed":false,"item":"vim","msg":"Conditional result was False"}},"task":{"name":"Deploy editor plugin"}}`,
`{"_event":"v2_playbook_on_stats","_timestamp":"2026-05-25T04:16:06Z","stats":{"web01":{"ok":2,"changed":1,"failures":0,"unreachable":0,"skipped":0}}}`,
}, "\n")
got := ParseJSONL([]byte(raw), "site.yml", nil)
statuses := make([]string, 0, len(got.Rows))
for _, row := range got.Rows {
statuses = append(statuses, row.Status)
}
want := []string{"start", "start", "ok", "changed", "failed", "unreachable", "skipped", "changed"}
if !reflect.DeepEqual(statuses, want) {
t.Fatalf("row statuses = %#v, want %#v", statuses, want)
}
if got.Rows[0].Timestamp != "04:16:00" || got.Rows[2].Summary != "already current" {
t.Fatalf("unexpected row details: %#v", got.Rows)
}
}
+76
View File
@@ -0,0 +1,76 @@
package ansible
import (
"strings"
"testing"
)
func TestCompactLogInfoDropsUnchangedFactPayload(t *testing.T) {
raw := strings.Join([]string{
`{"_event":"v2_playbook_on_play_start","play":{"name":"System configuration","path":"site.yml:2"}}`,
`{"_event":"v2_playbook_on_task_start","task":{"name":"Gathering Facts","path":"site.yml:2","duration":{"start":"2026-05-25T02:44:52.513931Z"}}}`,
`{"_event":"v2_runner_on_ok","hosts":{"web01":{"changed":false,"ansible_facts":{"huge_secret_payload":"` + strings.Repeat("x", 4096) + `"}}},"task":{"name":"Gathering Facts","path":"site.yml:2","duration":{"start":"2026-05-25T02:44:52.513931Z","end":"2026-05-25T02:44:54.405159Z"}}}`,
`{"_event":"v2_runner_on_ok","hosts":{"web01":{"changed":true,"msg":"updated","invocation":{"module_args":{"path":"/etc/example"}}}},"task":{"name":"Deploy config","path":"roles/example/tasks/main.yml:4","tags":["config"],"duration":{"start":"2026-05-25T02:44:55Z","end":"2026-05-25T02:44:57.250Z"}}}`,
`{"_event":"v2_playbook_on_stats","stats":{"web01":{"ok":2,"changed":1,"failures":0,"unreachable":0,"skipped":0}}}`,
"",
}, "\n")
got := string(CompactLog([]byte(raw), "site.yml", nil, "info", 0))
for _, want := range []string{
"PLAY [System configuration] site.yml:2",
"TASK [Gathering Facts] site.yml:2",
"changed web01",
"Deploy config /etc/example - updated (2.25s)",
"PLAY RECAP",
"web01 : ok=2 changed=1 unreachable=0 failed=0 skipped=0",
} {
if !strings.Contains(got, want) {
t.Fatalf("compact info log missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "huge_secret_payload") || strings.Contains(got, strings.Repeat("x", 512)) {
t.Fatalf("compact info log kept unchanged fact payload:\n%s", got)
}
}
func TestCompactLogWarnAndErrorKeepProblemsOnly(t *testing.T) {
raw := strings.Join([]string{
`{"_event":"v2_runner_on_ok","hosts":{"web01":{"changed":true,"msg":"updated"}},"task":{"name":"Deploy config","path":"roles/example/tasks/main.yml:4"}}`,
`{"_event":"v2_runner_on_failed","hosts":{"web02":{"changed":false,"msg":"permission denied"}},"task":{"name":"Restart service","path":"roles/example/tasks/main.yml:8"}}`,
`{"_event":"v2_runner_on_unreachable","hosts":{"web03":{"msg":"SSH timed out"}},"task":{"name":"Gathering Facts","path":"site.yml:2"}}`,
`{"_event":"v2_playbook_on_stats","stats":{"web01":{"ok":2,"changed":1,"failures":0,"unreachable":0,"skipped":0},"web02":{"ok":1,"changed":0,"failures":1,"unreachable":0,"skipped":0},"web03":{"ok":0,"changed":0,"failures":0,"unreachable":1,"skipped":0}}}`,
"ordinary progress line",
"fatal: permission denied",
"",
}, "\n")
warn := string(CompactLog([]byte(raw), "site.yml", nil, "warn", 0))
if strings.Contains(warn, "changed web01") || strings.Contains(warn, "ordinary progress line") {
t.Fatalf("warn log kept non-problem lines:\n%s", warn)
}
for _, want := range []string{"failed web02", "unreachable web03", "web02 : ok=1 changed=0 unreachable=0 failed=1 skipped=0", "fatal: permission denied"} {
if !strings.Contains(warn, want) {
t.Fatalf("warn log missing %q:\n%s", want, warn)
}
}
errLog := string(CompactLog([]byte(raw), "site.yml", nil, "error", 2))
if strings.Contains(errLog, "changed web01") || strings.Contains(errLog, "web01 : ok=2") {
t.Fatalf("error log kept non-error lines:\n%s", errLog)
}
for _, want := range []string{"failed web02", "unreachable web03", "ERROR exit_code=2"} {
if !strings.Contains(errLog, want) {
t.Fatalf("error log missing %q:\n%s", want, errLog)
}
}
}
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"} {
got := string(CompactLog(raw, "site.yml", nil, level, 0))
if got != string(raw) {
t.Fatalf("%s log changed raw output:\n%s", level, got)
}
}
}
+111
View File
@@ -23,6 +23,13 @@ type Job struct {
Group string
}
// DriftFixPlan is the set of safe apply jobs derived from drift findings.
type DriftFixPlan struct {
Jobs []Job
SkippedUntagged int
SkippedIneligible int
}
var fileNames = []string{"compliance.yaml", "compliance.yml"}
// LoadFromDir loads compliance.yaml from the playbook root when present.
@@ -126,6 +133,110 @@ func cleanTags(tags []string) []string {
return out
}
// PlanDriftFix turns structured check findings into host-limited apply jobs.
// When task tags are available, the apply is narrowed to those tags. Otherwise
// the mapped compliance playbook runs for the drifted host.
func PlanDriftFix(jobs []Job, inv *inventory.Inventory, findings []ansible.Finding) DriftFixPlan {
var plan DriftFixPlan
if inv == nil || len(jobs) == 0 || len(findings) == 0 {
return plan
}
seen := map[string]bool{}
for _, f := range findings {
if f.Status != "changed" || f.Host == "" || f.Playbook == "" {
continue
}
base, ok := matchingJobForFinding(jobs, inv, f)
if !ok {
plan.SkippedIneligible++
continue
}
tags := driftFixTags(base, f)
key := f.Playbook + "\x00" + f.Host + "\x00" + strings.Join(tags, ",")
if seen[key] {
continue
}
seen[key] = true
plan.Jobs = append(plan.Jobs, Job{
Playbook: f.Playbook,
Tags: tags,
Limit: f.Host,
Group: base.Group,
})
}
sort.Slice(plan.Jobs, func(i, j int) bool {
a, b := plan.Jobs[i], plan.Jobs[j]
if a.Group != b.Group {
return a.Group < b.Group
}
if a.Limit != b.Limit {
return a.Limit < b.Limit
}
if a.Playbook != b.Playbook {
return a.Playbook < b.Playbook
}
return strings.Join(a.Tags, ",") < strings.Join(b.Tags, ",")
})
return plan
}
func matchingJobForFinding(jobs []Job, inv *inventory.Inventory, f ansible.Finding) (Job, bool) {
for _, job := range jobs {
if job.Playbook != f.Playbook {
continue
}
if jobTargetsHost(inv, job, f.Host) {
return job, true
}
}
return Job{}, false
}
func jobTargetsHost(inv *inventory.Inventory, job Job, hostName string) bool {
if job.Limit == "all" {
return true
}
for _, h := range inv.Hosts {
if h.Name != hostName {
continue
}
return job.Limit == h.Name || job.Limit == h.Group
}
return job.Limit == hostName
}
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)
}
runTags := map[string]bool{}
for _, tag := range cleanTags(f.RunTags) {
runTags[tag] = true
}
var itemTags []string
for _, tag := range taskTags {
if !runTags[tag] {
itemTags = append(itemTags, tag)
}
}
if len(itemTags) > 0 {
return itemTags
}
return taskTags
}
// Summary is an aggregate view of one or more Ansible recaps.
type Summary struct {
OK int
+73
View File
@@ -42,6 +42,79 @@ func TestPlanEmpty(t *testing.T) {
}
}
func TestPlanDriftFixUsesChangedTaskTagsAndHostLimit(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", Group: "web"},
{Name: "web02", Group: "web"},
}}
base := []Job{{Playbook: "site.yml", Tags: []string{"sudo"}, Limit: "web", Group: "web"}}
findings := []ansible.Finding{
{Host: "web01", Playbook: "site.yml", Status: "changed", RunTags: []string{"sudo"}, TaskTags: []string{"sudo", "passwordless"}},
{Host: "web02", Playbook: "site.yml", Status: "ok", RunTags: []string{"sudo"}, TaskTags: []string{"sudo", "passwordless"}},
}
got := PlanDriftFix(base, inv, findings)
want := []Job{{Playbook: "site.yml", Tags: []string{"passwordless"}, Limit: "web01", Group: "web"}}
if !reflect.DeepEqual(got.Jobs, want) {
t.Fatalf("jobs = %#v want %#v", got.Jobs, want)
}
if got.SkippedUntagged != 0 || got.SkippedIneligible != 0 {
t.Fatalf("unexpected skips: %#v", got)
}
}
func TestPlanDriftFixDedupesAndSeparatesTags(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
base := []Job{{Playbook: "site.yml", Limit: "all"}}
findings := []ansible.Finding{
{Host: "web01", Playbook: "site.yml", Status: "changed", TaskTags: []string{"packages"}},
{Host: "web01", Playbook: "site.yml", Status: "changed", TaskTags: []string{"packages"}},
{Host: "web01", Playbook: "site.yml", Status: "changed", TaskTags: []string{"passwordless"}},
}
got := PlanDriftFix(base, inv, findings)
want := []Job{
{Playbook: "site.yml", Tags: []string{"packages"}, Limit: "web01"},
{Playbook: "site.yml", Tags: []string{"passwordless"}, Limit: "web01"},
}
if !reflect.DeepEqual(got.Jobs, want) {
t.Fatalf("jobs = %#v want %#v", got.Jobs, want)
}
}
func TestPlanDriftFixFallsBackToHostLimitedPlaybookForUntaggedDrift(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
base := []Job{{Playbook: "site.yml", Limit: "all"}}
findings := []ansible.Finding{
{Host: "web01", Playbook: "site.yml", Status: "changed"},
{Host: "web01", Playbook: "other.yml", Status: "changed", TaskTags: []string{"packages"}},
{Host: "web01", Playbook: "site.yml", Status: "failed", TaskTags: []string{"packages"}},
}
got := PlanDriftFix(base, inv, findings)
want := []Job{{Playbook: "site.yml", Limit: "web01"}}
if !reflect.DeepEqual(got.Jobs, want) {
t.Fatalf("jobs = %#v want %#v", got.Jobs, want)
}
if got.SkippedUntagged != 0 || got.SkippedIneligible != 1 {
t.Fatalf("skips = %#v", got)
}
}
func TestPlanDriftFixUsesMappedRunTagsForUntaggedTaggedScan(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
base := []Job{{Playbook: "site.yml", Tags: []string{"terminal"}, Limit: "all"}}
findings := []ansible.Finding{
{Host: "web01", Playbook: "site.yml", Status: "changed", RunTags: []string{"terminal"}},
}
got := PlanDriftFix(base, inv, findings)
want := []Job{{Playbook: "site.yml", Tags: []string{"terminal"}, Limit: "web01"}}
if !reflect.DeepEqual(got.Jobs, want) {
t.Fatalf("jobs = %#v want %#v", got.Jobs, want)
}
}
func TestLoadFromDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compliance.yaml")
+50
View File
@@ -101,10 +101,17 @@ type Config struct {
PlaybookDir string `yaml:"playbook_dir"`
InventoryPath string `yaml:"inventory_path"`
Runtime string `yaml:"runtime"`
LogLevel string `yaml:"log_level"`
RecentPlaybook string `yaml:"recent_playbook"`
PlaybooksGit *GitRepo `yaml:"playbooks_git,omitempty"`
InventoryGit *GitRepo `yaml:"inventory_git,omitempty"`
Compliance Compliance `yaml:"compliance,omitempty"`
// VM update playbook paths (relative to playbook_dir); defaults apply when empty.
CheckPlaybook string `yaml:"check_playbook,omitempty"`
UpdatePlaybook string `yaml:"update_playbook,omitempty"`
DockerPlaybook string `yaml:"docker_playbook,omitempty"`
RebootPlaybook string `yaml:"reboot_playbook,omitempty"`
}
// ConfigDir returns ~/.config/ansibletui (XDG config).
@@ -273,6 +280,7 @@ func freshDefaults() *Config {
PlaybookDir: filepath.Join(data, "playbooks"),
InventoryPath: filepath.Join(data, "inventory", "inventory.yml"),
Runtime: "auto",
LogLevel: "info",
PlaybooksGit: &GitRepo{
Enabled: false,
Path: filepath.Join(data, "playbooks"),
@@ -320,6 +328,7 @@ func (c *Config) Normalize() {
if c.Runtime == "" {
c.Runtime = "auto"
}
c.LogLevel = normalizeLogLevel(c.LogLevel)
if c.Compliance.Groups == nil {
c.Compliance.Groups = map[string][]CompliancePlaybook{}
}
@@ -342,6 +351,15 @@ func (c *Config) Normalize() {
_ = os.MkdirAll(runs, 0o755)
}
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "error", "warn", "info", "debug", "trace":
return strings.ToLower(strings.TrimSpace(level))
default:
return "info"
}
}
func normalizeGitRepo(r *GitRepo, defaultPath, defaultOnMissing, defaultFile string) {
if r.Path == "" {
r.Path = defaultPath
@@ -405,6 +423,38 @@ func (c *Config) EffectiveInventoryPath() string {
return filepath.Join(data, "inventory", "inventory.yml")
}
// EffectiveCheckPlaybook returns the pre-check playbook path relative to playbook_dir.
func (c *Config) EffectiveCheckPlaybook() string {
if c.CheckPlaybook != "" {
return c.CheckPlaybook
}
return "check_updates.yml"
}
// EffectiveUpdatePlaybook returns the OS update playbook path relative to playbook_dir.
func (c *Config) EffectiveUpdatePlaybook() string {
if c.UpdatePlaybook != "" {
return c.UpdatePlaybook
}
return "update_packages.yml"
}
// EffectiveDockerPlaybook returns the docker maintenance playbook path relative to playbook_dir.
func (c *Config) EffectiveDockerPlaybook() string {
if c.DockerPlaybook != "" {
return c.DockerPlaybook
}
return "docker_maintenance.yml"
}
// EffectiveRebootPlaybook returns the reboot playbook path relative to playbook_dir.
func (c *Config) EffectiveRebootPlaybook() string {
if c.RebootPlaybook != "" {
return c.RebootPlaybook
}
return "reboot.yml"
}
// SyncGitRepos runs startup sync for enabled repos with sync_on_startup.
func (c *Config) SyncGitRepos(ctx context.Context) []error {
var errs []error
+20
View File
@@ -44,3 +44,23 @@ func TestComplianceGroupsDefaulted(t *testing.T) {
t.Fatal("Compliance.Groups was nil")
}
}
func TestLogLevelDefaultsAndNormalizes(t *testing.T) {
cfg := Config{}
cfg.Normalize()
if cfg.LogLevel != "info" {
t.Fatalf("default LogLevel = %q, want info", cfg.LogLevel)
}
cfg.LogLevel = "DEBUG"
cfg.Normalize()
if cfg.LogLevel != "debug" {
t.Fatalf("normalized LogLevel = %q, want debug", cfg.LogLevel)
}
cfg.LogLevel = "chatty"
cfg.Normalize()
if cfg.LogLevel != "info" {
t.Fatalf("invalid LogLevel = %q, want info", cfg.LogLevel)
}
}
+251 -61
View File
@@ -32,6 +32,7 @@ const (
ScreenCmdFlow
ScreenRunDetails
ScreenHostDetails
ScreenVMUpdater
)
const (
@@ -41,9 +42,10 @@ const (
// Sidebar tabs
const (
TabServers = 0
TabJobs = 1
TabConfig = 2
TabServers = 0
TabJobs = 1
TabVMUpdate = 2
TabConfig = 3
)
// maxRecentRuns caps the runs loaded into memory (used by both home panel and jobs tab).
@@ -111,6 +113,7 @@ type App struct {
modeCursor int
outputLines []string
outputVp viewport.Model
logTable logTableModel
flowRecord *history.RunRecord
flowLog []byte
runCh <-chan tea.Msg
@@ -136,6 +139,9 @@ type App struct {
viewingHost string
logVp viewport.Model
// --- VM Updater tab ---
vmUpdater vmUpdaterModel
// Status / error messages
statusMsg string
errMsg string
@@ -181,6 +187,8 @@ func New(cfg *config.Config, inv *inventory.Inventory, hist *history.History) *A
formFields: fields,
filterInput: fi,
configEditInput: cfgEdit,
logTable: newLogTableModel(),
vmUpdater: newVMUpdaterModel(inv.Hosts),
}
app.loadComplianceMapping()
return app
@@ -221,30 +229,17 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case pingResultMsg:
for _, h := range a.inv.Hosts {
if h.Name == msg.host {
reach := msg.reachable
h.Reachable = &reach
if !msg.reachable && msg.err != nil {
h.LastError = msg.err.Error()
}
break
}
}
if msg.reachable {
a.statusMsg = fmt.Sprintf("%s TCP port is reachable", msg.host)
a.errMsg = ""
} else {
a.errMsg = fmt.Sprintf("ping %s: %v", msg.host, msg.err)
a.statusMsg = ""
}
return a, nil
case outputLineMsg:
a.outputLines = append(a.outputLines, msg.line)
a.outputVp.SetContent(strings.Join(a.outputLines, "\n"))
a.outputVp.GotoBottom()
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:
@@ -344,6 +339,169 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 := parseVMPackageProgress(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 = formatVMPackageSummary(progress)
row.Item = progress.packageName
} else if isVMPackageResultRow(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
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
} 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
} 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++
}
}
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 {
@@ -357,6 +515,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a.updateRunDetails(msg)
case ScreenHostDetails:
return a.updateHostDetails(msg)
case ScreenVMUpdater:
return a.updateVMUpdater(msg)
}
return a, nil
}
@@ -380,6 +540,8 @@ func (a *App) view() string {
return a.viewRunDetails()
case ScreenHostDetails:
return a.viewHostDetails()
case ScreenVMUpdater:
return a.viewVMUpdater()
}
return ""
}
@@ -393,15 +555,6 @@ func loadRunsCmd(hist *history.History) tea.Cmd {
}
}
func pingCmd(name, addr string, port int) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ok, err := runner.Probe(ctx, addr, port)
return pingResultMsg{host: name, reachable: ok, err: err}
}
}
func waitRun(ch <-chan tea.Msg) tea.Cmd {
if ch == nil {
return nil
@@ -430,6 +583,7 @@ func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode)
a.runCh = ch
a.runStarted = time.Now()
a.showRunLogs = false
a.logTable.Reset()
hist := a.hist
inv := a.inv
@@ -453,10 +607,8 @@ func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode)
}()
for line := range lineCh {
if compact, ok := ansible.CompactJSONLLine(line, playbook, nil); ok {
for _, compactLine := range strings.Split(compact, "\n") {
ch <- outputLineMsg{line: compactLine}
}
if rows := ansible.ParseJSONLLine(line, playbook, nil).Rows; len(rows) > 0 {
ch <- outputLineMsg{rows: rows}
} else if strings.TrimSpace(line) != "" {
ch <- outputLineMsg{line: line}
}
@@ -499,8 +651,9 @@ func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode)
if rec.DriftCount == 0 {
rec.DriftCount = countStatus(findings, "changed")
}
_ = hist.SaveWithFindings(rec, r.log, findings)
ch <- runDoneMsg{record: rec, log: r.log, findings: findings, err: 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)
@@ -515,6 +668,7 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
a.showRunLogs = false
a.outputLines = nil
a.outputVp.SetContent("")
a.logTable.Reset()
a.complianceMode = showScreen
a.complianceRunning = true
a.complianceAction = action
@@ -546,13 +700,13 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
jobs = expandGroupJobsPerHost(jobs, inv)
}
// Group jobs by Group field. Jobs in the same group run serially (one
// 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 _, job := range jobs {
g := job.Group
for i, job := range jobs {
g := complianceBatchKey(job, mode, i)
if _, ok := batchMap[g]; !ok {
batchOrder = append(batchOrder, g)
}
@@ -587,7 +741,6 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
ch <- complianceJobStartedMsg{job: job, index: idx, total: len(jobs)}
prefix := "[" + job.Label() + "] "
lineCh := make(chan string, 256)
argv := ansible.BuildPlaybookArgsWithTags(cfg.EffectiveInventoryPath(), job.Playbook, job.Limit, job.Tags, mode)
@@ -605,12 +758,18 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
}()
for line := range lineCh {
if compact, ok := ansible.CompactJSONLLine(line, job.Playbook, job.Tags); ok {
for _, compactLine := range strings.Split(compact, "\n") {
ch <- outputLineMsg{line: prefix + compactLine}
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: prefix + line}
ch <- outputLineMsg{line: "[" + job.Label() + "] " + line}
}
}
@@ -649,7 +808,8 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
Tags: job.Tags,
DriftCount: countStatus(findings, "changed"),
}
_ = hist.SaveWithFindings(jobRec, r.log, findings)
savedLog := ansible.CompactLog(r.log, job.Playbook, job.Tags, cfg.LogLevel, r.code)
_ = hist.SaveWithFindings(jobRec, savedLog, findings)
ch <- complianceJobDoneMsg{
job: job,
@@ -669,8 +829,8 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
firstErr = r.err
}
combined.WriteString(fmt.Sprintf("=== %s [%s] ===\n", job.Playbook, job.Label()))
combined.Write(r.log)
if len(r.log) == 0 || r.log[len(r.log)-1] != '\n' {
combined.Write(savedLog)
if len(savedLog) == 0 || savedLog[len(savedLog)-1] != '\n' {
combined.WriteByte('\n')
}
allRecaps = append(allRecaps, recaps...)
@@ -720,6 +880,13 @@ func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showS
return waitRun(ch)
}
func complianceBatchKey(job compliance.Job, mode ansible.Mode, index int) string {
if mode != ansible.ModeApply && job.Group == "" {
return fmt.Sprintf("universal:%d", index)
}
return job.Group
}
func (a *App) startStartupComplianceCheck() tea.Cmd {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
@@ -781,6 +948,14 @@ func findingsForHost(findings []ansible.Finding, host string) []ansible.Finding
return out
}
func flattenHostFindings(byHost map[string][]ansible.Finding) []ansible.Finding {
var out []ansible.Finding
for _, findings := range byHost {
out = append(out, findings...)
}
return out
}
func firstDiagnostic(findings []ansible.Finding) string {
for _, f := range findings {
if f.Diagnostic != "" {
@@ -882,15 +1057,15 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case ScreenHome:
return a.handleHomeMouseMsg(msg)
case ScreenRunDetails:
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
_, cmd := a.logTable.Update(msg)
return a, cmd
case ScreenCmdFlow:
if a.flowStep == StepExecuting || a.flowStep == StepDone {
var cmd tea.Cmd
a.outputVp, cmd = a.outputVp.Update(msg)
_, cmd := a.logTable.Update(msg)
return a, cmd
}
case ScreenVMUpdater:
return a.handleVMUpdaterMouse(msg)
}
return a, nil
}
@@ -922,34 +1097,50 @@ func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, rightX0, runsRowY0
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.
// Hamburger toggle — always works regardless of active tab.
if zone.Get("hamburger").InBounds(msg) {
a.sidebarExpanded = !a.sidebarExpanded
return a, nil
}
// Sidebar tab clicks.
for i, id := range []string{"tab-0", "tab-1", "tab-2"} {
// Sidebar tab clicks — always work regardless of active tab.
for i, id := range []string{"tab-0", "tab-1", "tab-2", "tab-3"} {
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 {
@@ -964,7 +1155,6 @@ func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
for i := range a.runs {
if zone.Get(fmt.Sprintf("run-%d", i)).InBounds(msg) {
if a.runCursor == i && a.activePanel == PanelRuns {
// Second click on already-selected run → open details.
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
@@ -999,7 +1189,7 @@ func hostsForJob(inv *inventory.Inventory, job compliance.Job) []*inventory.Host
func expandGroupJobsPerHost(jobs []compliance.Job, inv *inventory.Inventory) []compliance.Job {
expanded := make([]compliance.Job, 0, len(jobs))
for _, job := range jobs {
if job.Group == "" {
if job.Group == "" || job.Limit != job.Group {
expanded = append(expanded, job)
continue
}
+28 -6
View File
@@ -23,10 +23,20 @@ func (a *App) updateCmdFlow(msg tea.Msg) (tea.Model, tea.Cmd) {
// During execution: forward scroll keys to viewport, but also handle cancel
if a.flowStep == StepExecuting || a.flowStep == StepDone {
if km, ok := msg.(tea.KeyMsg); ok {
if a.showRunLogs {
if handled, cmd := a.logTable.Update(msg); handled {
return a, cmd
}
}
switch {
case key.Matches(km, keys.Logs):
a.showRunLogs = !a.showRunLogs
return a, nil
case key.Matches(km, keys.Update):
if a.flowStep == StepDone {
return a.openComplianceApply()
}
return a, nil
case key.Matches(km, keys.Back):
if a.flowStep == StepExecuting && a.cancelRun != nil {
a.cancelRun()
@@ -136,6 +146,9 @@ func (a *App) viewCmdFlow() string {
titleText := fmt.Sprintf("ansibleTUI / Check + Apply - %s", a.flowHost)
if a.complianceMode {
titleText = "ansibleTUI / Compliance Scan"
if a.complianceAction != "" {
titleText = "ansibleTUI / " + strings.Title(a.complianceAction)
}
}
title := titleStyle.Render(titleText)
bar := a.renderStepBar()
@@ -277,10 +290,10 @@ func (a *App) renderExecutingStep(w int) string {
subtleStyle.Render("Target: ") + boldStyle.Render(a.flowHost),
subtleStyle.Render("Elapsed: ") + dimStyle.Render(time.Since(a.runStarted).Round(time.Second).String()),
"",
dimStyle.Render("Raw logs are hidden. Press o to toggle output."),
dimStyle.Render("Logs are hidden. Press o to toggle output."),
}
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.outputVp.View())
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.logTable.View(w-6, vpH))
}
content := strings.Join(lines, "\n")
return panelStyle.Width(w - 2).Render(content)
@@ -320,10 +333,10 @@ func (a *App) renderDoneStep(w int) string {
subtleStyle.Render("Status: ") + statusStr + "\n" +
subtleStyle.Render(recap) + recapNote,
renderRunProgress(1, 1, w-8),
dimStyle.Render("Press o to toggle raw output, Esc to return to home"),
dimStyle.Render("Press o to toggle output, Esc to return to home"),
}
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Output log"), a.outputVp.View())
lines = append(lines, "", panelHeaderStyle.Render("Output log"), a.logTable.View(w-6, vpH))
}
return panelStyle.Width(w - 2).Render(strings.Join(lines, "\n"))
@@ -365,7 +378,12 @@ func (a *App) renderComplianceRunStep(w int, done bool) string {
if len(summary.ChangedHosts) > 0 {
lines = append(lines, subtleStyle.Render("Drifted: ")+runChangedStyle.Render(strings.Join(summary.ChangedHosts, ", ")))
}
lines = append(lines, "", dimStyle.Render("Raw logs are hidden. Press o to toggle output."))
if done && a.errMsg != "" {
lines = append(lines, formErrorStyle.Render(a.errMsg))
} else if done && a.statusMsg != "" {
lines = append(lines, dimStyle.Render(a.statusMsg))
}
lines = append(lines, "", dimStyle.Render("Logs are hidden. Press o to toggle output."))
if done {
lines = append(lines, dimStyle.Render("Press Esc to return to home."))
}
@@ -377,7 +395,7 @@ func (a *App) renderComplianceRunStep(w int, done bool) string {
}
a.outputVp.Height = vpH
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.outputVp.View())
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.logTable.View(w-6, vpH))
}
return panelStyle.Width(w - 2).Render(strings.Join(lines, "\n"))
}
@@ -398,9 +416,13 @@ func (a *App) renderCmdFlowFooter(w int) string {
switch a.flowStep {
case StepExecuting:
hints = hintKeyStyle.Render("o") + " " + hintDescStyle.Render("logs") + " " +
hintKeyStyle.Render("s/x") + " " + hintDescStyle.Render("filters") + " " +
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("detail") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("cancel")
case StepDone:
hints = hintKeyStyle.Render("o") + " " + hintDescStyle.Render("logs") + " " +
hintKeyStyle.Render("s/x") + " " + hintDescStyle.Render("filters") + " " +
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("detail") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back to home")
default:
hints = hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select") + " " +
+135 -10
View File
@@ -2,6 +2,7 @@ package ui
import (
"context"
"fmt"
"strings"
"time"
@@ -14,14 +15,22 @@ import (
)
const (
configFieldRemote = 0
configFieldBranch = 1
configFieldPath = 2
configFocusGeneral = -1
configGeneralLogLevel = 0
configFieldRemote = 0
configFieldBranch = 1
configFieldPath = 2
configFieldOnMissing = 3
configFieldFile = 4
configFieldFile = 4
)
var logLevelOptions = []string{"error", "warn", "info", "debug", "trace"}
func (a *App) configGitRepo() *config.GitRepo {
if a.configRepoFocus == configFocusGeneral {
return nil
}
if a.configRepoFocus == 0 {
return a.cfg.PlaybooksGit
}
@@ -29,6 +38,9 @@ func (a *App) configGitRepo() *config.GitRepo {
}
func (a *App) configFieldCount() int {
if a.configRepoFocus == configFocusGeneral {
return 1
}
if a.configRepoFocus == 1 {
return 5 // includes file
}
@@ -36,6 +48,18 @@ func (a *App) configFieldCount() int {
}
func (a *App) configFieldLabel(idx int) string {
if a.configRepoFocus == configFocusGeneral {
switch idx {
case configGeneralLogLevel:
return "log_level"
default:
return ""
}
}
return configGitFieldLabel(idx)
}
func configGitFieldLabel(idx int) string {
switch idx {
case configFieldRemote:
return "remote"
@@ -89,15 +113,26 @@ func (a *App) setConfigFieldValue(repo *config.GitRepo, idx int, val string) {
}
func (a *App) openConfigFieldEdit() {
if a.configRepoFocus == configFocusGeneral {
a.cycleConfigGeneralField(1)
return
}
repo := a.configGitRepo()
if repo == nil {
return
}
idx := a.configFieldFocus
label := a.configFieldLabel(idx)
label := configGitFieldLabel(idx)
a.configEditInput.SetValue(a.configFieldValue(repo, idx))
a.configEditInput.Placeholder = configFieldPlaceholder(idx)
a.configEditInput.Prompt = label + ": "
a.configureConfigEditInputWidth()
a.configEditInput.Focus()
a.configEditing = true
}
func (a *App) configureConfigEditInputWidth() {
w := a.width - 12
if w < 40 {
w = 40
@@ -106,8 +141,6 @@ func (a *App) openConfigFieldEdit() {
w = 100
}
a.configEditInput.Width = w
a.configEditInput.Focus()
a.configEditing = true
}
func configFieldPlaceholder(idx int) string {
@@ -128,6 +161,14 @@ func configFieldPlaceholder(idx int) string {
}
func (a *App) saveConfigFieldEdit() error {
if a.configRepoFocus == configFocusGeneral {
a.setConfigGeneralFieldValue(a.configFieldFocus, a.configEditInput.Value())
a.cfg.Normalize()
a.configEditing = false
a.configEditInput.Blur()
return a.cfg.Save()
}
repo := a.configGitRepo()
if repo == nil {
return nil
@@ -139,6 +180,42 @@ func (a *App) saveConfigFieldEdit() error {
return a.cfg.Save()
}
func (a *App) configGeneralFieldValue(idx int) string {
switch idx {
case configGeneralLogLevel:
return a.cfg.LogLevel
default:
return ""
}
}
func (a *App) setConfigGeneralFieldValue(idx int, val string) {
val = strings.TrimSpace(val)
switch idx {
case configGeneralLogLevel:
a.cfg.LogLevel = strings.ToLower(val)
}
}
func (a *App) cycleConfigGeneralField(delta int) {
if a.configFieldFocus != configGeneralLogLevel {
return
}
current := strings.ToLower(strings.TrimSpace(a.cfg.LogLevel))
idx := 2 // info default
for i, level := range logLevelOptions {
if level == current {
idx = i
break
}
}
idx = (idx + delta + len(logLevelOptions)) % len(logLevelOptions)
a.cfg.LogLevel = logLevelOptions[idx]
_ = a.cfg.Save()
a.statusMsg = fmt.Sprintf("saved log_level: %s", a.cfg.LogLevel)
a.errMsg = ""
}
func (a *App) cancelConfigFieldEdit() {
a.configEditing = false
a.configEditInput.Blur()
@@ -214,7 +291,7 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
case key.Matches(km, keys.Up):
if a.configRepoFocus > 0 {
if a.configRepoFocus > configFocusGeneral {
a.configRepoFocus--
a.configFieldFocus = configFieldRemote
}
@@ -228,12 +305,20 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
case key.Matches(km, keys.Left):
if a.configRepoFocus == configFocusGeneral {
a.cycleConfigGeneralField(-1)
return a, nil
}
if a.configFieldFocus > 0 {
a.configFieldFocus--
}
return a, nil
case key.Matches(km, keys.Right):
if a.configRepoFocus == configFocusGeneral {
a.cycleConfigGeneralField(1)
return a, nil
}
if a.configFieldFocus < a.configFieldCount()-1 {
a.configFieldFocus++
}
@@ -244,6 +329,10 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, textinput.Blink
case key.Matches(km, keys.Toggle):
if a.configRepoFocus == configFocusGeneral {
a.cycleConfigGeneralField(1)
return a, nil
}
if a.configRepoFocus == 0 && a.cfg.PlaybooksGit != nil {
a.cfg.PlaybooksGit.Enabled = !a.cfg.PlaybooksGit.Enabled
} else if a.cfg.InventoryGit != nil {
@@ -257,6 +346,10 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.configSyncing {
return a, nil
}
if a.configRepoFocus == configFocusGeneral {
a.statusMsg = "select a git repository to sync"
return a, nil
}
repo := "inventory"
if a.configRepoFocus == 0 {
repo = "playbooks"
@@ -269,6 +362,10 @@ func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.configSyncing {
return a, nil
}
if a.configRepoFocus == configFocusGeneral {
a.statusMsg = "select a git repository to push"
return a, nil
}
repo := "inventory"
if a.configRepoFocus == 0 {
repo = "playbooks"
@@ -310,13 +407,41 @@ func (a *App) updateConfigFieldEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a *App) configTabEnterCmd() tea.Cmd {
a.configFieldFocus = configFieldRemote
a.configRepoFocus = configFocusGeneral
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 := a.configFieldLabel(idx)
label := configGitFieldLabel(idx)
val := a.configFieldValue(repo, idx)
if val == "" {
val = dimStyle.Render("(empty)")
+104
View File
@@ -1,7 +1,11 @@
package ui
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
@@ -29,3 +33,103 @@ func TestApplyFindingsSetsAnsibleDiagnosticWithoutReachabilityProbe(t *testing.T
t.Fatalf("host findings = %#v", app.hostFindings)
}
}
func TestOpenComplianceApplyWithNoDriftShowsHelpfulMessage(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
cfg := &config.Config{
Compliance: config.Compliance{
Universal: []config.CompliancePlaybook{{Playbook: "site.yml"}},
},
}
app := New(cfg, inv, history.New(t.TempDir()))
_, cmd := app.openComplianceApply()
if cmd != nil {
t.Fatal("expected no command")
}
if app.errMsg != "no drift to fix" {
t.Fatalf("errMsg = %q", app.errMsg)
}
if app.screen != ScreenCmdFlow || app.flowStep != StepDone {
t.Fatalf("expected no-work fix to open completed command flow, screen=%v step=%v", app.screen, app.flowStep)
}
rendered := app.viewCmdFlow()
if !strings.Contains(rendered, "no drift to fix") {
t.Fatalf("completed command flow should show no-work message, got:\n%s", rendered)
}
}
func TestOpenComplianceApplyRunsHostLimitedFixForUntaggedDrift(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
cfg := &config.Config{
Compliance: config.Compliance{
Universal: []config.CompliancePlaybook{{Playbook: "site.yml"}},
},
}
app := New(cfg, inv, history.New(t.TempDir()))
app.hostFindings["web01"] = []ansible.Finding{{
Host: "web01",
Playbook: "site.yml",
Status: "changed",
}}
_, cmd := app.openComplianceApply()
if cmd == nil {
t.Fatal("expected fix command")
}
if app.screen != ScreenCmdFlow || app.flowStep != StepExecuting {
t.Fatalf("expected fix to open executing command flow, screen=%v step=%v", app.screen, app.flowStep)
}
if !strings.Contains(app.statusMsg, "fixing drift") {
t.Fatalf("statusMsg = %q", app.statusMsg)
}
}
func TestDriftFixFindingsLoadsLatestSavedFindings(t *testing.T) {
hist := history.New(t.TempDir())
rec := &history.RunRecord{
Playbook: "compliance scan",
Host: "fleet",
Mode: "check+diff",
Status: "drift",
StartTime: time.Date(2026, 5, 25, 10, 30, 0, 0, time.UTC),
EndTime: time.Date(2026, 5, 25, 10, 31, 0, 0, time.UTC),
}
findings := []ansible.Finding{{
Host: "web01",
Playbook: "site.yml",
Status: "changed",
TaskTags: []string{"packages"},
}}
if err := hist.SaveWithFindings(rec, []byte("log\n"), findings); err != nil {
t.Fatal(err)
}
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "web01"}}}
app := New(&config.Config{}, inv, hist)
app.runs = []*history.RunRecord{rec}
got, source := app.driftFixFindings()
if len(got) != 1 || got[0].Host != "web01" {
t.Fatalf("findings = %#v", got)
}
if source != rec.TimeLabel() {
t.Fatalf("source = %q, want %q", source, rec.TimeLabel())
}
if len(app.hostFindings["web01"]) != 1 {
t.Fatalf("hostFindings were not restored: %#v", app.hostFindings)
}
}
func TestUpdateKeyWorksFromCompletedCommandFlow(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.screen = ScreenCmdFlow
app.flowStep = StepDone
model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
app = model.(*App)
if app.errMsg == "" {
t.Fatal("expected u on completed command flow to invoke fix flow and report missing mappings")
}
}
+313 -93
View File
@@ -5,6 +5,7 @@ import (
"os"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
@@ -14,6 +15,7 @@ import (
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
@@ -32,6 +34,16 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -43,20 +55,26 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(km, keys.Tab):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 1) % 3
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 + 2) % 3
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
@@ -104,24 +122,10 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case key.Matches(km, keys.Ping):
if h := a.selectedServer(); h != nil {
addr := h.AnsibleHost
if addr == "" {
addr = h.Name
}
a.statusMsg = fmt.Sprintf("probing %s…", h.Name)
return a, pingCmd(h.Name, addr, h.AnsiblePort)
}
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.Apply):
case key.Matches(km, keys.Update):
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
@@ -205,6 +209,7 @@ func (a *App) openCmdFlow(host string, defaultMode ansible.Mode) {
a.flowMode = defaultMode
a.flowStep = StepPlaybook
a.outputLines = nil
a.logTable.Reset()
a.flowRecord = nil
a.flowLog = nil
a.showRunLogs = false
@@ -241,9 +246,107 @@ func (a *App) openComplianceApply() (tea.Model, tea.Cmd) {
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("applying compliance: %d mapped playbooks", len(jobs))
return a, a.startComplianceRun(jobs, ansible.ModeApply, true, "compliance apply")
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 ----
@@ -300,40 +403,65 @@ func (a *App) renderSidebar(w, h int) string {
type tabDef struct {
icon string
short string // 3-char abbreviation shown in collapsed mode
label string
id int
}
tabs := []tabDef{
{Icons.Servers, "SRV", "Servers", TabServers},
{Icons.Jobs, "JOB", "Jobs", TabJobs},
{Icons.Config, "CFG", "Config", TabConfig},
{Icons.Servers, "Servers", TabServers},
{Icons.Jobs, "Jobs", TabJobs},
{Icons.Updates, "Updates", TabVMUpdate},
{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
}
accentActive := lipgloss.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(colorCyan).
Background(lipgloss.Color("#0d2040")).
Foreground(colorCyan).
Bold(true).
Width(innerW).
Align(lipgloss.Center).
Padding(1, 0)
accentInactive := lipgloss.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color("#1a2530")). // near-invisible border keeps alignment
Foreground(colorDim).
Width(innerW).
Align(lipgloss.Center).
Padding(1, 0)
var lines []string
lines = append(lines, hamburgerRow, "", "")
for _, t := range tabs {
var rendered string
if a.sidebarExpanded {
// Expanded: icon + full label.
text := t.icon + " " + t.label
if t.id == a.sidebarTab {
rendered = navActiveStyle.Width(w).Render(text)
rendered = accentActive.Render(text)
} else {
rendered = navStyle.Width(w).Render(text)
rendered = accentInactive.Render(text)
}
} else {
// Collapsed: icon + 3-char abbreviation so tabs are always readable.
text := t.icon + " " + t.short
if t.id == a.sidebarTab {
rendered = navActiveStyle.Width(w).Render(text)
rendered = accentActive.Render(t.icon)
} else {
rendered = navStyle.Width(w).Render(text)
rendered = accentInactive.Render(t.icon)
}
}
zoneID := fmt.Sprintf("tab-%d", t.id)
lines = append(lines, zone.Mark(zoneID, rendered), "", "")
lines = append(lines, zone.Mark(zoneID, rendered), "")
}
content := strings.Join(lines, "\n")
@@ -344,6 +472,8 @@ func (a *App) renderMainContent(w, bodyH int) string {
switch a.sidebarTab {
case TabJobs:
return a.renderJobsTab(w, bodyH)
case TabVMUpdate:
return a.renderVMUpdaterInline(w, bodyH)
case TabConfig:
return a.renderConfigTab(w, bodyH)
default:
@@ -377,7 +507,7 @@ 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 := 7
detailH := 6
if contentH < 16 {
detailH = 5
}
@@ -401,43 +531,53 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
total, clean, drift, failed, _ := fleetCounts(a.inv.Hosts)
innerW := max(1, w-4)
// Spread the four stat badges evenly across the full panel width.
cellW := innerW / 4
centerBadge := func(b string) string {
bw := lipgloss.Width(b)
if bw >= cellW {
return b
}
lp := (cellW - bw) / 2
rp := cellW - bw - lp
return strings.Repeat(" ", lp) + b + strings.Repeat(" ", rp)
// 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))
b4 := badgeFailedStyle.Render(Icons.Failed + fmt.Sprintf(" %d failed ", failed))
const badgeGap = 2
totalBadgeW := lipgloss.Width(b1) + lipgloss.Width(b2) + lipgloss.Width(b3) + lipgloss.Width(b4) + 3*badgeGap
lpad := (innerW - totalBadgeW) / 2
if lpad < 0 {
lpad = 0
}
badges := centerBadge(badgeHostsStyle.Render(fmt.Sprintf(" %d hosts ", total))) +
centerBadge(badgeCleanStyle.Render(Icons.Clean+" "+fmt.Sprintf(" %d clean ", clean))) +
centerBadge(badgeDriftStyle.Render(Icons.Drift+" "+fmt.Sprintf(" %d drift ", drift))) +
centerBadge(badgeFailedStyle.Render(Icons.Failed+" "+fmt.Sprintf(" %d failed ", failed)))
gap := strings.Repeat(" ", badgeGap)
badges := strings.Repeat(" ", lpad) + b1 + gap + b2 + gap + b3 + gap + b4
lines := []string{
boldStyle.Render("Fleet Summary"),
badges,
}
// Compliance progress / results inline (replaces standalone Scan Results panel).
// Compliance progress, latest action feedback, and recent results inline.
if a.complianceRunning {
progressLine := statusScanning.Render(Icons.Running+" "+a.statusMsg)
progressLine := statusScanning.Render(Icons.Running + " " + a.statusMsg)
lines = append(lines, "", progressLine)
} else if len(a.complianceJobResults) > 0 {
} else {
if a.errMsg != "" {
lines = append(lines, "", formErrorStyle.Render(Icons.Failed+" "+a.errMsg))
} else if a.statusMsg != "" {
lines = append(lines, "", dimStyle.Render(a.statusMsg))
}
}
if !a.complianceRunning && len(a.complianceJobResults) > 0 {
lines = append(lines, "")
// How many result rows fit inside the panel height.
maxRows := h - 5 // header(1) + badges(1) + blank(1) + border(2)
maxRows := h - 2 - len(lines)
if maxRows < 1 {
maxRows = 1
}
for i, r := range a.complianceJobResults {
if i >= maxRows {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more — see JOBS tab", len(a.complianceJobResults)-i)))
break
}
// Pre-compute display labels so we can sort and align.
type resultRow struct {
label string
statusStr string
}
maxLabelW := 0
rows := make([]resultRow, 0, len(a.complianceJobResults))
for _, r := range a.complianceJobResults {
pb := strings.TrimPrefix(r.playbook, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
@@ -452,21 +592,41 @@ func (a *App) renderFleetSummaryPanel(w, h int) string {
case "drift":
statusStr = runChangedStyle.Render("drift")
default:
// "unreachable", "failed", etc. — no icon, just colored text.
statusStr = runFailedStyle.Render(r.status)
}
lines = append(lines, " "+pb+": "+statusStr)
if len([]rune(pb)) > maxLabelW {
maxLabelW = len([]rune(pb))
}
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))
})
for i, row := range rows {
if i >= maxRows {
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)
}
} else if a.errMsg != "" {
lines = append(lines, "", formErrorStyle.Render(Icons.Failed+" "+a.errMsg))
} else if a.statusMsg != "" {
lines = append(lines, "", dimStyle.Render(a.statusMsg))
}
return panelStyle.Width(innerW).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
// 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 {
allLines = allLines[:targetLines]
}
for len(allLines) < targetLines {
allLines = append(allLines, "")
}
return panelStyle.Width(innerW).Render(strings.Join(allLines, "\n"))
}
func (a *App) renderServerDetail(w, h int) string {
header := panelHeaderStyle.Render("Server Detail")
innerW := w - 4
@@ -506,19 +666,25 @@ func (a *App) renderServerDetail(w, h int) string {
lines = append(lines, lbl("State:")+stateVal)
}
// Drift count.
// Error (left) + Drift count (right) on one line.
driftCount := countHostDrift(a.hostFindings[host.Name])
if driftCount > 0 {
lines = append(lines, lbl("Drift:")+dimStyle.Render(fmt.Sprintf("%d item(s)", driftCount)))
}
if host.LastError != "" {
maxErrW := innerW - lblW - 1
if maxErrW < 10 {
maxErrW = 10
if host.LastError != "" || driftCount > 0 {
if host.LastError != "" && driftCount > 0 {
maxErrW := halfW - lblW - 1
if maxErrW < 10 {
maxErrW = 10
}
errText := truncate(host.LastError, maxErrW)
lines = append(lines, twoCol("Error:", formErrorStyle.Render(errText), "Drift:", dimStyle.Render(fmt.Sprintf("%d item(s)", driftCount))))
} else if host.LastError != "" {
maxErrW := innerW - lblW - 1
if maxErrW < 10 {
maxErrW = 10
}
lines = append(lines, lbl("Error:")+formErrorStyle.Render(truncate(host.LastError, maxErrW)))
} else {
lines = append(lines, lbl("Drift:")+dimStyle.Render(fmt.Sprintf("%d item(s)", driftCount)))
}
errText := truncate(host.LastError, maxErrW)
lines = append(lines, lbl("Error:")+formErrorStyle.Render(errText))
}
if findings := a.hostFindings[host.Name]; len(findings) > 0 {
@@ -526,7 +692,20 @@ func (a *App) renderServerDetail(w, h int) string {
}
content := header + "\n" + strings.Join(lines, "\n")
return panelStyle.Width(innerW).Height(h - 2).Render(content)
// 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 {
targetLines = 1
}
if len(contentLines) > targetLines {
contentLines = contentLines[:targetLines]
}
for len(contentLines) < targetLines {
contentLines = append(contentLines, "")
}
return panelStyle.Width(innerW).Render(strings.Join(contentLines, "\n"))
}
func (a *App) renderServersPanel(w, h int) string {
@@ -736,23 +915,57 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
}
func (a *App) renderFooter(w int) string {
hint := func(k, icon, desc string) string {
return hintKeyStyle.Render(k) + " " + hintDescStyle.Render(icon+" "+desc)
k := func(key string) string { return hintKeyStyle.Render(key) }
d := func(desc string) string { return hintDescStyle.Render(desc) }
h := func(key, icon, desc string) string { return k(key) + " " + d(icon+" "+desc) }
sep := " "
var hints []string
switch a.sidebarTab {
case TabJobs:
hints = []string{
k("↑↓") + " " + d("select"),
k("Enter") + " " + d("details"),
k("q") + " " + d("quit"),
}
case TabVMUpdate:
m := &a.vmUpdater
hints = []string{k("↑↓←→") + " " + d("navigate")}
if !m.hostFiltering {
hints = append(hints, k("/")+" "+d("filter"))
}
if !m.running {
if m.phase != vmPhaseIdle {
hints = append(hints, h("u", Icons.Play, "update"))
}
hints = append(hints, h("b", Icons.Restart, "reboot"), h("d", Icons.Docker, "docker"))
} else {
hints = append(hints, vmBadgeUpdating.Render(vmPhaseLabel(m.phase)+"…"))
}
hints = append(hints, k("Enter")+" "+d("select"), k("q")+" "+d("quit"))
case TabConfig:
hints = []string{
k("↑↓") + " " + d("navigate"),
h("e", Icons.Edit, "edit"),
h("t", Icons.Toggle, "toggle"),
h("s", Icons.Sync, "sync"),
h("S", Icons.Push, "push"),
k("q") + " " + d("quit"),
}
default: // TabServers
hints = []string{
k("↑↓") + " " + d("select"),
k("/") + " " + d("filter"),
h("a", Icons.Add, "add"),
h("e", Icons.Edit, "edit"),
h("c", Icons.Scan, "scan"),
h("u", Icons.Play, "update"),
k("Enter") + " " + d("details"),
k("q") + " " + d("quit"),
}
}
hints := []string{
hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select"),
hintKeyStyle.Render("/") + " " + hintDescStyle.Render("filter"),
hint("a", Icons.Add, "add"),
hint("e", Icons.Edit, "edit"),
hint("p", Icons.Ping, "ping"),
hint("c", Icons.Scan, "scan"),
hint("f", Icons.Fix, "fix"),
hint("r", Icons.Play, "apply"),
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("details"),
hintKeyStyle.Render("m") + " " + hintDescStyle.Render(Icons.Hamburger+" menu"),
hintKeyStyle.Render("q") + " " + hintDescStyle.Render("quit"),
}
bar := strings.Join(hints, " ")
bar := strings.Join(hints, sep)
return lipgloss.NewStyle().
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
@@ -866,15 +1079,22 @@ func (a *App) renderConfigTab(w, bodyH int) string {
subtleStyle.Render(" Inventory: ") + dimStyle.Render(a.cfg.EffectiveInventoryPath()),
subtleStyle.Render(" Runtime: ") + dimStyle.Render(a.cfg.Runtime),
"",
panelHeaderStyle.Render("Git repositories"),
panelHeaderStyle.Render("Application"),
}
lines = append(lines, a.renderConfigGeneralBlock(a.configRepoFocus == configFocusGeneral)...)
lines = append(lines,
"",
panelHeaderStyle.Render("Git repositories"),
)
lines = append(lines, a.renderConfigRepoBlock("Playbooks", a.cfg.PlaybooksGit, a.configGitPlaybooks, a.configRepoFocus == 0)...)
lines = append(lines, "")
lines = append(lines, a.renderConfigRepoBlock("Inventory", a.cfg.InventoryGit, a.configGitInventory, a.configRepoFocus == 1)...)
lines = append(lines, "",
dimStyle.Render("↑↓ repo ←→ field e edit s sync S push t enable"),
dimStyle.Render("↑↓ section ←→ field/value e edit s sync S push t toggle"),
dimStyle.Render("Config: "+cfgPath+" · examples/ansibletui.example.yaml"),
)
+93 -2
View File
@@ -4,9 +4,11 @@ import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
@@ -54,6 +56,31 @@ func TestTruncateMinLength(t *testing.T) {
}
}
func TestComplianceBatchKeyParallelizesUniversalChecks(t *testing.T) {
jobs := []compliance.Job{
{Playbook: "site.yml", Limit: "all"},
{Playbook: "xcp_guest_tools.yml", Limit: "all"},
{Playbook: "dns.yml", Limit: "dns", Group: "dns"},
}
first := complianceBatchKey(jobs[0], ansible.ModeCheckDiff, 0)
second := complianceBatchKey(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" {
t.Fatalf("grouped check job key = %q, want dns", got)
}
if got := complianceBatchKey(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 != "" {
t.Fatalf("second universal apply job key = %q, want empty group key", got)
}
}
// ---- fleetCounts ----
func TestFleetCountsEmpty(t *testing.T) {
@@ -241,6 +268,27 @@ func TestFleetSummaryShowsComplianceResultsAfterScan(t *testing.T) {
}
}
func TestFleetSummaryShowsLatestFeedbackBeforeOldResults(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{
Hosts: []*inventory.Host{{Name: "web-1", DriftState: "drift"}},
}, history.New(t.TempDir()))
app.width = 120
app.height = 40
app.errMsg = "no drift to fix"
app.complianceJobResults = []complianceJobResult{
{label: "all", playbook: "playbooks/site.yml", status: "drift", changed: 3},
}
rendered := app.renderFleetSummaryPanel(app.width, 10)
if !strings.Contains(rendered, "no drift to fix") {
t.Fatalf("fleet summary should show latest feedback, got:\n%s", rendered)
}
if !strings.Contains(rendered, "site") {
t.Fatalf("fleet summary should keep old result context when room, got:\n%s", rendered)
}
}
func TestFleetSummaryShowsProgressDuringComplianceScan(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
@@ -287,6 +335,50 @@ func TestTitleBarNoPathsShown(t *testing.T) {
}
}
func TestConfigTabShowsEditableLogLevel(t *testing.T) {
cfg := &config.Config{LogLevel: "info"}
cfg.Normalize()
app := New(cfg, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
app.height = 40
app.configRepoFocus = configFocusGeneral
app.configFieldFocus = configGeneralLogLevel
rendered := app.renderConfigTab(100, 30)
if !strings.Contains(rendered, "Application") {
t.Fatal("config tab should show Application section")
}
if !strings.Contains(rendered, "log_level") || !strings.Contains(rendered, "info") {
t.Fatalf("config tab should show editable log_level, got:\n%s", rendered)
}
if !strings.Contains(rendered, "cycle") {
t.Fatalf("selected log_level should advertise cycling, got:\n%s", rendered)
}
if !strings.Contains(rendered, "remote") || !strings.Contains(rendered, "branch") {
t.Fatalf("git field labels should still render while General is selected, got:\n%s", rendered)
}
}
func TestConfigLogLevelEnterCyclesWithoutEditing(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cfg := &config.Config{LogLevel: "info"}
cfg.Normalize()
app := New(cfg, &inventory.Inventory{}, history.New(t.TempDir()))
app.configRepoFocus = configFocusGeneral
app.configFieldFocus = configGeneralLogLevel
model, _ := app.updateConfigTab(tea.KeyMsg{Type: tea.KeyEnter})
app = model.(*App)
if app.configEditing {
t.Fatal("log_level should not enter free-text edit mode")
}
if app.cfg.LogLevel != "debug" {
t.Fatalf("LogLevel = %q, want debug after cycling from info", app.cfg.LogLevel)
}
}
// ---- Server Detail is simplified ----
func TestServerDetailNoIPGroupTimestamp(t *testing.T) {
@@ -378,4 +470,3 @@ func TestRecentRunsStylesDifferByStatus(t *testing.T) {
t.Error("drift and failed run styles should have different foreground colors")
}
}
+4 -6
View File
@@ -10,10 +10,9 @@ type keyMap struct {
Filter key.Binding
Add key.Binding
Edit key.Binding
Ping key.Binding
Check key.Binding
Apply key.Binding
Fix key.Binding
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
Back key.Binding
Quit key.Binding
@@ -35,10 +34,9 @@ var keys = keyMap{
Filter: key.NewBinding(key.WithKeys("/")),
Add: key.NewBinding(key.WithKeys("a")),
Edit: key.NewBinding(key.WithKeys("e")),
Ping: key.NewBinding(key.WithKeys("p")),
Check: key.NewBinding(key.WithKeys("c")),
Apply: key.NewBinding(key.WithKeys("r")),
Fix: key.NewBinding(key.WithKeys("f")),
Update: key.NewBinding(key.WithKeys("u")),
Reboot: key.NewBinding(key.WithKeys("b")),
Enter: key.NewBinding(key.WithKeys("enter")),
Back: key.NewBinding(key.WithKeys("esc")),
Quit: key.NewBinding(key.WithKeys("q")),
+446
View File
@@ -0,0 +1,446 @@
package ui
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"ansibletui/internal/ansible"
)
var logStatusFilters = []string{"all", "failed", "unreachable", "running", "changed", "skipped", "ok"}
type logTableModel struct {
rows []ansible.LogRow
plain []string
cursor int
offset int
serverFilter string
statusFilter string
detailOpen bool
detailVp viewport.Model
focused bool // true when this pane has keyboard focus
zonePrefix string // when non-empty, each row is wrapped with zone.Mark(zonePrefix+index)
}
func newLogTableModel() logTableModel {
return logTableModel{
serverFilter: "all",
statusFilter: "all",
detailVp: viewport.New(80, 12),
}
}
func (m *logTableModel) Reset() {
m.rows = nil
m.plain = nil
m.cursor = 0
m.offset = 0
m.detailOpen = false
m.detailVp.SetContent("")
}
func (m *logTableModel) SetRows(rows []ansible.LogRow) {
m.rows = append([]ansible.LogRow(nil), rows...)
m.plain = nil
m.clampCursor()
}
func (m *logTableModel) SetPlain(text string) {
m.rows = nil
m.plain = splitNonEmptyLines(text)
m.cursor = 0
m.offset = 0
m.detailOpen = false
m.detailVp.SetContent("")
}
func (m *logTableModel) AddRow(row ansible.LogRow) {
m.rows = append(m.rows, row)
m.clampCursor()
}
func (m *logTableModel) AddPlain(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
m.plain = append(m.plain, line)
}
func (m *logTableModel) Update(msg tea.Msg) (bool, tea.Cmd) {
if m.detailOpen {
if km, ok := msg.(tea.KeyMsg); ok {
if key.Matches(km, keys.Back) || key.Matches(km, keys.Quit) {
m.detailOpen = false
return true, nil
}
}
var cmd tea.Cmd
m.detailVp, cmd = m.detailVp.Update(msg)
return true, cmd
}
km, ok := msg.(tea.KeyMsg)
if !ok {
return false, nil
}
switch {
case key.Matches(km, keys.Up):
if m.cursor > 0 {
m.cursor--
}
return true, nil
case key.Matches(km, keys.Down):
if m.cursor < len(m.filteredRows())-1 {
m.cursor++
}
return true, nil
case key.Matches(km, keys.Enter):
if len(m.filteredRows()) > 0 {
m.detailOpen = true
m.detailVp.GotoTop()
}
return true, nil
}
switch km.String() {
case "s":
m.cycleServerFilter()
return true, nil
case "x":
m.cycleStatusFilter()
return true, nil
}
return false, nil
}
func (m *logTableModel) View(w, h int) string {
if w < 40 {
w = 40
}
if h < 4 {
h = 4
}
if m.detailOpen {
return m.detailView(w, h)
}
rows := m.filteredRows()
m.clampCursor()
m.keepCursorVisible(h - 3)
lines := []string{m.filterBar(len(rows))}
if len(m.rows) == 0 {
lines = append(lines, m.plainView(w, h-1))
return strings.Join(lines, "\n")
}
timeW, statusW, serverW, eventW := 8, 11, 16, 14
fixed := timeW + statusW + serverW + eventW + 13
taskW := 28
summaryW := w - fixed - taskW
if summaryW < 18 {
summaryW = 18
taskW = w - fixed - summaryW
}
if taskW < 14 {
taskW = 14
}
header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %s",
timeW, "Time", statusW, "Status", serverW, "Server", eventW, "Event", taskW, "Task", "Summary")
lines = append(lines, tableHeaderStyle.Render(header))
maxVisible := h - len(lines)
if maxVisible < 1 {
maxVisible = 1
}
end := m.offset + maxVisible
if end > len(rows) {
end = len(rows)
}
for i := m.offset; i < end; i++ {
row := rows[i]
raw := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %s",
timeW, truncate(row.Timestamp, timeW),
statusW, truncate(row.Status, statusW),
serverW, truncate(row.Server, serverW),
eventW, truncate(row.Event, eventW),
taskW, truncate(logRowTask(row), taskW),
truncate(row.Summary, summaryW),
)
var line string
switch {
case i == m.cursor && m.focused:
line = tableRowSelected.Render(raw)
case isProblemStatus(row.Status):
line = runFailedStyle.Render(raw)
default:
line = raw
}
if m.zonePrefix != "" {
line = zone.Mark(m.zonePrefix+strconv.Itoa(i), line)
}
lines = append(lines, line)
}
if len(rows) == 0 {
lines = append(lines, dimStyle.Render(" no log rows match the current filters"))
}
return strings.Join(lines, "\n")
}
func (m *logTableModel) filterBar(visible int) string {
return subtleStyle.Render("Server: ") + boldStyle.Render(m.serverFilter) +
dimStyle.Render(" ") + subtleStyle.Render("Status: ") + renderLogFilterStatus(m.statusFilter) +
dimStyle.Render(" ") + subtleStyle.Render("Rows: ") + boldStyle.Render(fmt.Sprintf("all parsed (%d/%d)", visible, len(m.rows))) +
dimStyle.Render(" s server x status Enter detail")
}
func (m *logTableModel) plainView(w, h int) string {
if len(m.plain) == 0 {
return dimStyle.Render(" no log output")
}
maxVisible := h
if maxVisible < 1 {
maxVisible = 1
}
start := len(m.plain) - maxVisible
if start < 0 {
start = 0
}
lines := make([]string, 0, len(m.plain[start:]))
for _, line := range m.plain[start:] {
lines = append(lines, truncate(line, w-2))
}
return strings.Join(lines, "\n")
}
func (m *logTableModel) detailView(w, h int) string {
rows := m.filteredRows()
if len(rows) == 0 {
m.detailOpen = false
return m.View(w, h)
}
if m.cursor >= len(rows) {
m.cursor = len(rows) - 1
}
row := rows[m.cursor]
m.detailVp.Width = w - 4
m.detailVp.Height = h - 2
if m.detailVp.Width < 20 {
m.detailVp.Width = 20
}
if m.detailVp.Height < 2 {
m.detailVp.Height = 2
}
m.detailVp.SetContent(logRowDetail(row, m.detailVp.Width))
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorCyan).
Padding(0, 1).
Width(w - 2).
Render(m.detailVp.View())
}
func (m *logTableModel) filteredRows() []ansible.LogRow {
out := make([]ansible.LogRow, 0, len(m.rows))
for _, row := range m.rows {
if m.serverFilter != "all" && row.Server != m.serverFilter {
continue
}
if m.statusFilter != "all" && row.Status != m.statusFilter {
continue
}
out = append(out, row)
}
return out
}
func (m *logTableModel) cycleServerFilter() {
servers := []string{"all"}
seen := map[string]bool{}
for _, row := range m.rows {
if row.Server == "" || seen[row.Server] {
continue
}
seen[row.Server] = true
servers = append(servers, row.Server)
}
sort.Strings(servers[1:])
m.serverFilter = nextInList(servers, m.serverFilter)
m.cursor = 0
m.offset = 0
}
func (m *logTableModel) cycleStatusFilter() {
m.statusFilter = nextInList(logStatusFilters, m.statusFilter)
m.cursor = 0
m.offset = 0
}
func (m *logTableModel) clampCursor() {
rows := m.filteredRows()
if len(rows) == 0 {
m.cursor = 0
m.offset = 0
return
}
if m.cursor >= len(rows) {
m.cursor = len(rows) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *logTableModel) keepCursorVisible(maxVisible int) {
if maxVisible < 1 {
maxVisible = 1
}
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+maxVisible {
m.offset = m.cursor - maxVisible + 1
}
if m.offset < 0 {
m.offset = 0
}
}
func nextInList(values []string, current string) string {
if len(values) == 0 {
return current
}
for i, value := range values {
if value == current {
return values[(i+1)%len(values)]
}
}
return values[0]
}
func renderLogFilterStatus(status string) string {
if isProblemStatus(status) {
return runFailedStyle.Render(status)
}
return boldStyle.Render(status)
}
func isProblemStatus(status string) bool {
return status == "failed" || status == "unreachable"
}
func logRowTask(row ansible.LogRow) string {
if row.Task != "" {
return row.Task
}
if row.Play != "" {
return row.Play
}
return row.Playbook
}
func logRowDetail(row ansible.LogRow, width int) string {
field := func(label, value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return subtleStyle.Render(padRight(label+":", 10)) + value
}
var lines []string
for _, line := range []string{
field("Time", row.Timestamp),
field("Status", renderFindingStatus(row.Status)),
field("Server", row.Server),
field("Event", row.Event),
field("Playbook", row.Playbook),
field("Play", row.Play),
field("Task", row.Task),
field("Path", firstNonEmpty(row.Path, row.TaskPath)),
field("Action", row.Action),
} {
if line != "" {
lines = append(lines, line)
}
}
if row.Changed != nil {
lines = append(lines, field("Changed", fmt.Sprint(*row.Changed)))
}
if row.Recap != nil {
lines = append(lines, field("Recap", fmt.Sprintf("ok=%d changed=%d unreachable=%d failed=%d skipped=%d",
row.Recap.OK, row.Recap.Changed, row.Recap.Unreachable, row.Recap.Failed, row.Recap.Skipped)))
}
if row.Summary != "" {
lines = append(lines, "", boldStyle.Render("Summary"), wordWrapStr(row.Summary, width))
}
for _, block := range []struct {
name string
value string
}{
{"Message", row.Msg},
{"Stdout", row.Stdout},
{"Stderr", row.Stderr},
{"Item", row.Item},
} {
if strings.TrimSpace(block.value) != "" {
lines = append(lines, "", boldStyle.Render(block.name), wordWrapStr(block.value, width))
}
}
for i, diff := range row.Diff {
lines = append(lines, "", boldStyle.Render(fmt.Sprintf("Diff %d", i+1)))
if diff.BeforeHeader != "" || diff.AfterHeader != "" {
lines = append(lines, field("Before", diff.BeforeHeader), field("After", diff.AfterHeader))
}
if strings.TrimSpace(diff.Before) != "" {
lines = append(lines, subtleStyle.Render("Before content:"), wordWrapStr(diff.Before, width))
}
if strings.TrimSpace(diff.After) != "" {
lines = append(lines, subtleStyle.Render("After content:"), wordWrapStr(diff.After, width))
}
}
if len(row.Raw) > 0 {
lines = append(lines, "", boldStyle.Render("Raw JSON"), prettyJSON(row.Raw))
}
lines = append(lines, "", dimStyle.Render("Esc closes detail"))
return strings.Join(lines, "\n")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func prettyJSON(raw []byte) string {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
return string(data)
}
func splitNonEmptyLines(text string) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package ui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
)
func TestLogTableRendersHeadersAndTruncates(t *testing.T) {
m := newLogTableModel()
m.SetRows([]ansible.LogRow{{
Timestamp: "04:16:00",
Status: "ok",
Server: "xen-orchestra",
Event: "runner_ok",
Task: "A very long task name that should not force horizontal scrolling",
Summary: "A very long summary that should be truncated to fit inside the table width instead of pushing off screen",
}})
got := m.View(80, 8)
for _, want := range []string{"Server: all", "Status: all", "Time", "Status", "Server", "Event", "Task", "Summary"} {
if !strings.Contains(got, want) {
t.Fatalf("rendered table missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "pushing off screen") {
t.Fatalf("summary was not truncated:\n%s", got)
}
}
func TestLogTableHighlightsFailures(t *testing.T) {
m := newLogTableModel()
m.SetRows([]ansible.LogRow{
{Status: "ok", Server: "web01", Event: "runner_ok", Task: "Ok"},
{Status: "failed", Server: "web02", Event: "runner_failed", Task: "Failed"},
})
got := m.View(100, 8)
want := runFailedStyle.Render("failed")
if !strings.Contains(got, want) {
t.Fatalf("failed row did not use failure styling; want rendered status %q in:\n%s", want, got)
}
}
func TestLogTableFiltersServerAndStatus(t *testing.T) {
m := newLogTableModel()
m.SetRows([]ansible.LogRow{
{Status: "ok", Server: "web01", Event: "runner_ok", Task: "No change"},
{Status: "failed", Server: "web02", Event: "runner_failed", Task: "Restart service"},
{Status: "unreachable", Server: "web03", Event: "runner_unreachable", Task: "Gathering Facts"},
})
m.serverFilter = "web02"
m.statusFilter = "failed"
got := m.View(100, 8)
if !strings.Contains(got, "web02") || !strings.Contains(got, "Restart service") {
t.Fatalf("filtered table missing selected failure:\n%s", got)
}
if strings.Contains(got, "web01") || strings.Contains(got, "web03") {
t.Fatalf("filtered table kept other servers:\n%s", got)
}
}
func TestLogTableStatusCycleIncludesFailedOnlyAndClampsCursor(t *testing.T) {
m := newLogTableModel()
m.SetRows([]ansible.LogRow{
{Status: "ok", Server: "web01", Event: "runner_ok", Task: "No change"},
{Status: "failed", Server: "web02", Event: "runner_failed", Task: "Restart service"},
})
m.cursor = 1
m.Update(keyMsg("x"))
if m.statusFilter != "failed" {
t.Fatalf("statusFilter = %q, want failed", m.statusFilter)
}
if m.cursor != 0 {
t.Fatalf("cursor = %d, want clamped to 0", m.cursor)
}
got := m.View(90, 8)
if !strings.Contains(got, "failed") || strings.Contains(got, "No change") {
t.Fatalf("failed-only view wrong:\n%s", got)
}
}
func TestLogTableDetailIncludesRawAndLongFields(t *testing.T) {
m := newLogTableModel()
m.SetRows([]ansible.LogRow{{
Timestamp: "04:16:03",
Status: "failed",
Server: "web02",
Event: "runner_failed",
Task: "Restart service",
Summary: "permission denied",
Msg: "permission denied while restarting service",
Stdout: "short stdout",
Stderr: "long stderr content that should appear in the scrollable detail view",
Raw: []byte(`{"_event":"v2_runner_on_failed","hosts":{"web02":{"msg":"permission denied"}}}`),
}})
m.Update(keyMsg("enter"))
got := m.View(100, 80)
for _, want := range []string{"Server", "web02", "Summary", "permission denied", "Stderr", "Raw JSON", "v2_runner_on_failed"} {
if !strings.Contains(got, want) {
t.Fatalf("detail view missing %q:\n%s", want, got)
}
}
}
func keyMsg(key string) tea.KeyMsg {
if key == "enter" {
return tea.KeyMsg{Type: tea.KeyEnter}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key), Alt: false}
}
+58 -7
View File
@@ -6,16 +6,10 @@ import (
"ansibletui/internal/history"
)
// pingResultMsg is delivered when a TCP probe completes.
type pingResultMsg struct {
host string
reachable bool
err error
}
// outputLineMsg carries one line of streamed playbook output.
type outputLineMsg struct {
line string
rows []ansible.LogRow
}
// runDoneMsg is delivered when a playbook run finishes.
@@ -71,3 +65,60 @@ type gitStatusMsg struct {
playbooks string
inventory string
}
// vmOutputLineMsg carries one line of streamed VM-updater playbook output.
type vmOutputLineMsg struct {
line string
rows []ansible.LogRow
}
// vmCheckStartedMsg is sent when the pre-check starts for one host.
type vmCheckStartedMsg struct{ host string }
// vmCheckDoneMsg is sent when the pre-check finishes for one host.
type vmCheckDoneMsg struct {
host string
updates int
docker bool
reboot bool
osFamily string
distro string
err error
}
// vmUpdateStartedMsg is sent when the update playbook starts on one host.
type vmUpdateStartedMsg struct{ host string }
// vmUpdateDoneMsg is sent when the update playbook finishes on one host.
type vmUpdateDoneMsg struct {
host string
ok bool
changed int
refreshed bool
updates int
docker bool
reboot bool
osFamily string
distro string
}
// vmRebootStartedMsg is sent when the reboot playbook starts on one host.
type vmRebootStartedMsg struct{ host string }
// vmRebootDoneMsg is sent when the reboot playbook finishes on one host.
type vmRebootDoneMsg struct {
host string
ok bool
refreshed bool
updates int
docker bool
reboot bool
osFamily string
distro string
}
// vmUpdateAllDoneMsg is sent when all parallel VM update jobs have finished.
type vmUpdateAllDoneMsg struct {
exitCode int
host string
}
+86 -63
View File
@@ -13,22 +13,34 @@ import (
"ansibletui/internal/history"
)
// buildCmdStr returns the ansible-playbook command line as a single string.
func buildCmdStr(inventoryPath string, run *history.RunRecord) string {
argv := ansible.BuildPlaybookArgsWithTags(inventoryPath, run.Playbook, run.Host, run.Tags, ansible.ModeFromString(run.Mode))
return strings.Join(argv, " ")
}
func (a *App) openRunDetailsScreen(run *history.RunRecord) {
a.screen = ScreenRunDetails
a.viewingRun = run
a.viewingFindings, _ = a.hist.LoadFindings(run)
a.logTable.Reset()
if len(a.viewingFindings) > 0 {
a.logVp.SetContent(renderFindingsTable(a.viewingFindings))
logBytes, _ := a.hist.LoadLog(run)
if parsed := ansible.ParseJSONL(logBytes, run.Playbook, run.Tags); len(parsed.Rows) > 0 {
a.logTable.SetRows(parsed.Rows)
} else if len(a.viewingFindings) > 0 {
a.logTable.SetRows(logRowsFromFindings(a.viewingFindings))
} else {
logBytes, _ := a.hist.LoadLog(run)
a.logVp.SetContent(string(logBytes))
a.logTable.SetPlain(string(logBytes))
}
a.logVp.GotoTop()
}
func (a *App) updateRunDetails(msg tea.Msg) (tea.Model, tea.Cmd) {
if km, ok := msg.(tea.KeyMsg); ok {
if handled, cmd := a.logTable.Update(msg); handled {
return a, cmd
}
switch {
case key.Matches(km, keys.Back), key.Matches(km, keys.Quit):
a.screen = ScreenHome
@@ -37,8 +49,8 @@ func (a *App) updateRunDetails(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
}
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
_, cmd := a.logTable.Update(msg)
return a, cmd
}
@@ -65,35 +77,35 @@ func (a *App) viewRunDetails() string {
recap += " (check mode: changed = would change, not applied)"
}
// Build the full command so the user can copy and run it manually.
argv := ansible.BuildPlaybookArgs(
a.cfg.EffectiveInventoryPath(),
run.Playbook,
run.Host,
ansible.ModeFromString(run.Mode),
)
cmdStr := strings.Join(argv, " ")
// Compact meta: Host · Mode · Status · Started on one line; Recap and Summary next; Command last.
dot := dimStyle.Render(" · ")
line1 := subtleStyle.Render("Host: ") + boldStyle.Render(run.Host) +
dot + subtleStyle.Render("Mode: ") + boldStyle.Render(run.Mode) +
dot + subtleStyle.Render("Status: ") + statusStr +
dot + subtleStyle.Render("Started: ") + run.StartTime.Format("2006-01-02 15:04:05")
line2 := subtleStyle.Render("Recap: ") + dimStyle.Render(recap)
line3 := subtleStyle.Render("Summary: ") + dimStyle.Render(a.runFindingSummary())
line4 := subtleStyle.Render("Command: ") + dimStyle.Render(buildCmdStr(a.cfg.EffectiveInventoryPath(), run))
meta := strings.Join([]string{
subtleStyle.Render("Playbook: ") + boldStyle.Render(run.Playbook),
subtleStyle.Render("Host: ") + boldStyle.Render(run.Host),
subtleStyle.Render("Mode: ") + boldStyle.Render(run.Mode),
subtleStyle.Render("Status: ") + statusStr,
subtleStyle.Render("Recap: ") + dimStyle.Render(recap),
subtleStyle.Render("Summary: ") + dimStyle.Render(a.runFindingSummary()),
subtleStyle.Render("Started: ") + run.StartTime.Format("2006-01-02 15:04:05"),
subtleStyle.Render("Command: ") + dimStyle.Render(cmdStr),
}, "\n")
meta := strings.Join([]string{line1, line2, line3, line4}, "\n")
a.logVp.Width = w - 4
vpH := a.height - 16
vpH := a.height - 12
if vpH < 3 {
vpH = 3
}
a.logVp.Height = vpH
logHeader := panelHeaderStyle.Render("Output log")
logPanel := panelStyle.Width(w - 2).Render(logHeader + "\n" + a.logVp.View())
var logContent string
logContent = a.logTable.View(w-6, vpH)
logPanel := panelStyle.Width(w - 2).Render(logHeader + "\n" + logContent)
var footerHints string
footerHints = hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select") + " " +
hintKeyStyle.Render("s/x") + " " + hintDescStyle.Render("filters") + " " +
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("detail") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back")
footer := lipgloss.NewStyle().
BorderTop(true).
@@ -101,8 +113,7 @@ func (a *App) viewRunDetails() string {
BorderForeground(colorBorder).
Width(w).
Padding(0, 1).
Render(hintKeyStyle.Render("↑↓/PgUp/PgDn") + " " + hintDescStyle.Render("scroll") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back"))
Render(footerHints)
return lipgloss.JoinVertical(lipgloss.Left,
" "+title,
@@ -113,42 +124,30 @@ func (a *App) viewRunDetails() string {
)
}
// renderFindingsTable formats findings as a scrollable columnar table.
func renderFindingsTable(findings []ansible.Finding) string {
const statusColW = 12
const hostColW = 16
const taskColW = 24
hdr := fmt.Sprintf(" %-*s %-*s %-*s %s", statusColW, "Status", hostColW, "Host", taskColW, "Task", "Detail")
div := " " + strings.Repeat("─", 82)
lines := []string{
dimStyle.Render(hdr),
dimStyle.Render(div),
// wordWrapStr wraps s to lines of at most maxW runes.
func wordWrapStr(s string, maxW int) string {
if maxW <= 0 {
return s
}
for _, f := range findings {
statusStr := renderFindingStatus(f.Status)
host := padRight(f.Host, hostColW)
task := f.Task
if task == "" {
task = f.Play
words := strings.Fields(s)
if len(words) == 0 {
return s
}
var lines []string
line := ""
for _, w := range words {
if line == "" {
line = w
} else if len([]rune(line))+1+len([]rune(w)) <= maxW {
line += " " + w
} else {
lines = append(lines, line)
line = w
}
taskStr := padRight(task, taskColW)
detail := f.Summary
if f.Diagnostic != "" {
detail = f.Diagnostic
}
if detail == "" && f.Path != "" {
detail = f.Path
}
line := " " + padANSI(statusStr, statusColW+2) + " " + host + " " + taskStr + " " + dimStyle.Render(detail)
}
if line != "" {
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
@@ -167,6 +166,30 @@ func renderFindingStatus(status string) string {
}
}
func logRowsFromFindings(findings []ansible.Finding) []ansible.LogRow {
rows := make([]ansible.LogRow, 0, len(findings))
for _, f := range findings {
summary := f.Summary
if f.Diagnostic != "" {
summary = f.Diagnostic
}
rows = append(rows, ansible.LogRow{
Status: f.Status,
Server: f.Host,
Event: "finding",
Playbook: f.Playbook,
Play: f.Play,
Task: f.Task,
TaskPath: f.TaskPath,
Path: f.Path,
Summary: summary,
Item: f.Item,
Raw: f.Raw,
})
}
return rows
}
func (a *App) runFindingSummary() string {
findings := a.viewingFindings
if len(findings) == 0 {
+57
View File
@@ -122,6 +122,17 @@ var (
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)
)
// Title bar
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
@@ -159,6 +170,7 @@ type iconSet struct {
Servers string // nf-fa-server
Jobs string // nf-fa-list
Config string // nf-fa-cog
Updates string // nf-fa-download (package updates tab)
Hamburger string // nf-fa-bars
Clean string // nf-fa-check
Drift string // nf-fa-exclamation-triangle
@@ -173,6 +185,23 @@ type iconSet struct {
Ping string // nf-fa-wifi
Git string // nf-dev-git_branch
Running string // nf-fa-spinner
Restart string // nf-fa-refresh (reboot action)
Docker string // nf-md-docker
Sync string // nf-fa-refresh alias for git sync
Push string // nf-fa-upload
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) 
}
// Get returns the icon for the given lowercase field name, or "" if not found.
@@ -181,6 +210,7 @@ func (s iconSet) Get(name string) string {
"servers": s.Servers,
"jobs": s.Jobs,
"config": s.Config,
"updates": s.Updates,
"hamburger": s.Hamburger,
"clean": s.Clean,
"drift": s.Drift,
@@ -195,6 +225,16 @@ func (s iconSet) Get(name string) string {
"ping": s.Ping,
"git": s.Git,
"running": s.Running,
"distro_debian": s.DistroDebian,
"distro_ubuntu": s.DistroUbuntu,
"distro_fedora": s.DistroFedora,
"distro_redhat": s.DistroRedHat,
"distro_centos": s.DistroCentOS,
"distro_rocky": s.DistroRocky,
"distro_alma": s.DistroAlma,
"distro_arch": s.DistroArch,
"distro_alpine": s.DistroAlpine,
"distro_generic": s.DistroGeneric,
}
return m[name]
}
@@ -204,6 +244,7 @@ var Icons = iconSet{
Servers: "", // nf-fa-sitemap (distinct from hamburger bars)
Jobs: "", // nf-fa-list
Config: "", // nf-fa-cog
Updates: "", // nf-fa-download
Hamburger: "", // nf-fa-bars
Clean: "", // nf-fa-check
Drift: "", // nf-fa-exclamation-triangle
@@ -218,6 +259,22 @@ var Icons = iconSet{
Ping: "", // nf-fa-wifi
Git: "", // nf-dev-git_branch
Running: "", // nf-fa-spinner
Restart: "", // nf-fa-refresh
Docker: "", // nf-fa-cube (container)
Sync: "", // nf-fa-refresh (git sync)
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
}
// ---- Render helpers ----
File diff suppressed because it is too large Load Diff
+900
View File
@@ -0,0 +1,900 @@
package ui
import (
"os"
"path/filepath"
"testing"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
// ---- helpers ----
func makeHosts(specs [][2]string) []*inventory.Host {
hosts := make([]*inventory.Host, len(specs))
for i, s := range specs {
hosts[i] = &inventory.Host{Name: s[0], Group: s[1]}
}
return hosts
}
// loadActualInventory returns the hosts from ~/.ansibletui/inventory/inventory.yml.
// Returns nil, skip message if the file doesn't exist (CI or fresh machine).
func loadActualInventory(t *testing.T) []*inventory.Host {
t.Helper()
path := filepath.Join(os.Getenv("HOME"), ".ansibletui", "inventory", "inventory.yml")
inv, err := inventory.Load(path)
if os.IsNotExist(err) || (err == nil && len(inv.Hosts) == 0) {
t.Skipf("actual inventory not found at %s — skipping integration test", path)
}
if err != nil {
t.Fatalf("failed to load inventory: %v", err)
}
return inv.Hosts
}
// ---- newVMUpdaterModel ----
func TestNewVMUpdaterModelSortsHostsByGroup(t *testing.T) {
hosts := makeHosts([][2]string{
{"node-1", "swarm"}, {"adguard-1", "dns"}, {"kube-1", "k8s"},
})
m := newVMUpdaterModel(hosts)
// Sorted: dns < k8s < swarm
want := []string{"adguard-1", "kube-1", "node-1"}
for i, h := range m.hosts {
if h.Name != want[i] {
t.Errorf("host[%d] = %q, want %q", i, h.Name, want[i])
}
}
}
func TestNewVMUpdaterModelSortsHostsWithinGroup(t *testing.T) {
hosts := makeHosts([][2]string{
{"kube-3", "k8s"}, {"kube-1", "k8s"}, {"kube-2", "k8s"},
})
m := newVMUpdaterModel(hosts)
want := []string{"kube-1", "kube-2", "kube-3"}
for i, h := range m.hosts {
if h.Name != want[i] {
t.Errorf("host[%d] = %q, want %q", i, h.Name, want[i])
}
}
}
func TestNewVMUpdaterModelInitializesAllStatesIdle(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}, {"adguard-1", "dns"}})
m := newVMUpdaterModel(hosts)
for _, h := range m.hosts {
s := m.hostStates[h.Name]
if s.status != vmStatusIdle {
t.Errorf("host %q initial status = %v, want vmStatusIdle", h.Name, s.status)
}
}
}
func TestNewVMUpdaterModelStartsInIdlePhase(t *testing.T) {
m := newVMUpdaterModel(makeHosts([][2]string{{"node-1", "swarm"}}))
if m.phase != vmPhaseIdle {
t.Errorf("initial phase = %v, want vmPhaseIdle", m.phase)
}
}
func TestNewVMUpdaterModelEmptyHosts(t *testing.T) {
m := newVMUpdaterModel(nil)
if len(m.hosts) != 0 {
t.Errorf("expected 0 hosts, got %d", len(m.hosts))
}
}
// ---- vmGroupHosts ----
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)
if len(bm["swarm"]) != 2 {
t.Errorf("swarm group = %d hosts, want 2", len(bm["swarm"]))
}
if len(bm["dns"]) != 1 {
t.Errorf("dns group = %d hosts, want 1", len(bm["dns"]))
}
// Order preserves first-seen sequence: swarm, dns, k8s
if order[0] != "swarm" || order[1] != "dns" || order[2] != "k8s" {
t.Errorf("unexpected order: %v", order)
}
}
func TestVMGroupHostsFallsBackToDefault(t *testing.T) {
hosts := makeHosts([][2]string{{"orphan", ""}})
bm, order := vmGroupHosts(hosts)
if _, ok := bm["default"]; !ok {
t.Errorf("expected 'default' group for host with no group")
}
if order[0] != "default" {
t.Errorf("order[0] = %q, want 'default'", order[0])
}
}
// ---- vmSelectHost ----
func TestVMSelectHostUpdatesFilterAndSelected(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{
{"node-1", "swarm"}, {"node-2", "swarm"},
}))
app.vmSelectHost(1)
if app.vmUpdater.selectedHost != "node-2" {
t.Errorf("selectedHost = %q, want 'node-2'", app.vmUpdater.selectedHost)
}
if app.vmUpdater.vmLogTable.serverFilter != "node-2" {
t.Errorf("logTable.serverFilter = %q, want 'node-2'", app.vmUpdater.vmLogTable.serverFilter)
}
}
func TestVMSelectHostIgnoresOutOfRange(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-1", "swarm"}}))
app.vmSelectHost(0) // valid first
app.vmSelectHost(5) // out of range
if app.vmUpdater.selectedHost != "node-1" {
t.Errorf("selectedHost changed on out-of-range index: got %q", app.vmUpdater.selectedHost)
}
}
func TestVMSelectHostIgnoresNegativeIndex(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-1", "swarm"}}))
app.vmSelectHost(-1)
if app.vmUpdater.selectedHost != "" {
t.Errorf("selectedHost set on negative index: got %q", app.vmUpdater.selectedHost)
}
}
// ---- parseVMCheckResult ----
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)
}
if !r.docker {
t.Errorf("docker = false, want true")
}
if r.reboot {
t.Errorf("reboot = true, want false")
}
if r.osFamily != "Debian" {
t.Errorf("osFamily = %q, want 'Debian'", r.osFamily)
}
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 != "" {
t.Errorf("expected zero value, got %+v", r)
}
}
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)
}
}
func TestParseVMCheckResultHandlesEmptyServerField(t *testing.T) {
// When server field is empty, the row matches any host (single-host run).
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)
}
if !r.reboot {
t.Errorf("reboot = false, want true")
}
if r.distro != "Rocky" {
t.Errorf("distro = %q, want 'Rocky'", r.distro)
}
}
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)
}
}
// ---- distroIcon ----
func TestDistroIconKnownDistros(t *testing.T) {
cases := []struct {
distro string
osFamily string
}{
{"Ubuntu", "Debian"},
{"Debian", "Debian"},
{"Fedora", "RedHat"},
{"Rocky", "RedHat"},
{"AlmaLinux", "RedHat"},
{"CentOS", "RedHat"},
{"RedHat", "RedHat"},
{"ArchLinux", "Archlinux"},
{"Alpine", "Alpine"},
}
for _, c := range cases {
icon := distroIcon(c.distro, c.osFamily)
if icon == "" {
t.Errorf("distroIcon(%q, %q) returned empty string", c.distro, c.osFamily)
}
}
}
func TestDistroIconFallsBackToGeneric(t *testing.T) {
icon := distroIcon("UnknownDistro", "UnknownFamily")
if icon == "" {
t.Errorf("distroIcon with unknown distro returned empty string")
}
if icon != Icons.DistroGeneric {
t.Errorf("expected generic icon for unknown distro, got %q", icon)
}
}
// ---- renderVMStatus ----
func TestRenderVMStatusAllStatuses(t *testing.T) {
statuses := []vmStatus{
vmStatusIdle, vmStatusChecking, vmStatusPending,
vmStatusUpToDate, vmStatusUpdating, vmStatusDone, vmStatusFailed,
}
for _, s := range statuses {
out := renderVMStatus(s)
if out == "" {
t.Errorf("renderVMStatus(%v) returned empty string", s)
}
}
}
func TestRenderVMStatusDoneShowsUpdated(t *testing.T) {
out := renderVMStatus(vmStatusDone)
if !containsString(out, "updated") {
t.Fatalf("done status should render as updated, got %q", out)
}
}
// ---- vmPhaseLabel ----
func TestVMPhaseLabelCoversAllPhases(t *testing.T) {
phases := []vmPhase{vmPhaseIdle, vmPhaseChecking, vmPhaseUpdating, vmPhaseDockerMaint, vmPhaseDone}
for _, p := range phases {
label := vmPhaseLabel(p)
if label == "" {
t.Errorf("vmPhaseLabel(%v) returned empty string", p)
}
}
}
// ---- integration: actual inventory ----
func TestIntegration_InventoryHostCount(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))
}
}
func TestIntegration_InventoryGroups(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)
}
}
}
func TestIntegration_InventoryKnownHosts(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))
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)
}
}
}
func TestIntegration_AllHostsInitializedIdle(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
for _, h := range m.hosts {
if s := m.hostStates[h.Name]; s.status != vmStatusIdle {
t.Errorf("host %q: initial status = %v, want idle", h.Name, s.status)
}
}
}
func TestIntegration_VMGroupHostsFiveGroups(t *testing.T) {
hosts := loadActualInventory(t)
m := newVMUpdaterModel(hosts)
_, order := vmGroupHosts(m.hosts)
if len(order) != 5 {
t.Errorf("expected 5 groups, got %d: %v", len(order), order)
}
}
func TestIntegration_SelectHostFiltersLogTable(t *testing.T) {
hosts := loadActualInventory(t)
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(hosts)
// Seed some log rows for two hosts.
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"},
}
// 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)
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)
}
}
}
func TestIntegration_RenderVMHostTableNonempty(t *testing.T) {
hosts := loadActualInventory(t)
app := newTestApp(t)
app.width = 200
app.height = 50
app.vmUpdater = newVMUpdaterModel(hosts)
app.vmSelectHost(0)
out := app.renderVMHostTable(100, 40)
for _, h := range hosts {
if !containsString(out, h.Name) {
t.Errorf("renderVMHostTable output missing host %q", h.Name)
}
}
}
func TestIntegration_RenderVMHostTableShowsAllGroups(t *testing.T) {
hosts := loadActualInventory(t)
app := newTestApp(t)
app.width = 200
app.height = 50
app.vmUpdater = newVMUpdaterModel(hosts)
out := app.renderVMHostTable(100, 40)
for _, group := range []string{"dns", "k8s", "swarm", "media", "appliance"} {
if !containsString(out, group) {
t.Errorf("renderVMHostTable output missing group %q", group)
}
}
}
func TestRenderVMHostTableMovesOSIconIntoHostColumn(t *testing.T) {
app := newTestApp(t)
app.width = 120
app.height = 30
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
app.vmUpdater = newVMUpdaterModel(hosts)
app.vmUpdater.hostStates["node-1"] = vmHostState{
status: vmStatusUpToDate,
osFamily: "RedHat",
distro: "Rocky",
}
out := app.renderVMHostTable(100, 20)
if containsString(out, " OS") {
t.Fatalf("host table still renders standalone OS header:\n%s", out)
}
if !containsString(out, Icons.DistroRocky+" node-1") {
t.Fatalf("host table missing distro icon next to host:\n%s", out)
}
if containsString(out, "Rocky") {
t.Fatalf("host table should not render OS distro text:\n%s", out)
}
}
func TestVMUpdateDoneRefreshResetsPendingCount(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusUpdating, updates: 128, updatesTotal: 128, updatesDone: 127}
_, _ = app.Update(vmUpdateDoneMsg{
host: "node-3",
ok: true,
refreshed: true,
updates: 0,
docker: true,
reboot: true,
osFamily: "RedHat",
distro: "Rocky",
})
s := app.vmUpdater.hostStates["node-3"]
if s.status != vmStatusDone {
t.Fatalf("status = %v, want vmStatusDone", s.status)
}
if s.updates != 0 {
t.Fatalf("updates = %d, want 0", s.updates)
}
if !s.hasDocker || !s.rebootNeeded || s.distro != "Rocky" {
t.Fatalf("refresh fields not applied: %+v", s)
}
}
func TestVMUpdateDoneRefreshKeepsRemainingPending(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusUpdating, updates: 128, updatesTotal: 128}
_, _ = app.Update(vmUpdateDoneMsg{host: "node-3", ok: true, refreshed: true, updates: 2})
s := app.vmUpdater.hostStates["node-3"]
if s.status != vmStatusPending {
t.Fatalf("status = %v, want vmStatusPending", s.status)
}
if s.updates != 2 {
t.Fatalf("updates = %d, want 2", s.updates)
}
}
func TestVMUpdateFailedDoesNotClearPendingCount(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusUpdating, updates: 128}
_, _ = app.Update(vmUpdateDoneMsg{host: "node-3", ok: false})
s := app.vmUpdater.hostStates["node-3"]
if s.status != vmStatusFailed {
t.Fatalf("status = %v, want vmStatusFailed", s.status)
}
if s.updates != 128 {
t.Fatalf("updates = %d, want original count 128", s.updates)
}
}
func TestVMRebootFailureKeepsRetryConfirmationAvailable(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusPending, rebootNeeded: true}
app.vmUpdater.phase = vmPhaseRebooting
app.vmUpdater.running = true
_, _ = app.Update(vmRebootStartedMsg{host: "node-3"})
_, _ = app.Update(vmRebootDoneMsg{host: "node-3", ok: false})
_, _ = app.Update(vmUpdateAllDoneMsg{host: "node-3"})
s := app.vmUpdater.hostStates["node-3"]
if s.status != vmStatusFailed {
t.Fatalf("status = %v, want vmStatusFailed", s.status)
}
if !s.rebootNeeded {
t.Fatalf("rebootNeeded = false, want true so b can retry")
}
if !containsString(app.errMsg, "press b to retry") {
t.Fatalf("errMsg = %q, want reboot retry hint", app.errMsg)
}
_, _ = app.updateVMUpdater(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}})
if !app.vmUpdater.rebootConfirm || app.vmUpdater.rebootTargetHost != "node-3" {
t.Fatalf("reboot confirmation not reopened: confirm=%v target=%q", app.vmUpdater.rebootConfirm, app.vmUpdater.rebootTargetHost)
}
}
func TestVMRebootDoneIgnoresUnrelatedFailedHosts(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-1", "swarm"}, {"node-2", "swarm"}}))
app.vmUpdater.hostStates["node-1"] = vmHostState{status: vmStatusFailed}
app.vmUpdater.hostStates["node-2"] = vmHostState{status: vmStatusUpdating, rebootNeeded: true}
app.vmUpdater.phase = vmPhaseRebooting
app.vmUpdater.running = true
_, _ = app.Update(vmRebootDoneMsg{host: "node-2", ok: true})
_, _ = app.Update(vmUpdateAllDoneMsg{host: "node-2"})
if app.errMsg != "" {
t.Fatalf("errMsg = %q, want no error from unrelated failed host", app.errMsg)
}
if !containsString(app.statusMsg, "reboot complete for node-2") {
t.Fatalf("statusMsg = %q, want reboot completion for target", app.statusMsg)
}
}
func TestVMRebootPromptRendersWhenHostTableIsFull(t *testing.T) {
app := newTestApp(t)
app.width = 120
app.height = 20
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{
{"node-1", "swarm"}, {"node-2", "swarm"}, {"node-3", "swarm"},
{"node-4", "swarm"}, {"node-5", "swarm"}, {"node-6", "swarm"},
{"node-7", "swarm"}, {"node-8", "swarm"}, {"node-9", "swarm"},
}))
app.vmUpdater.rebootConfirm = true
app.vmUpdater.rebootTargetHost = "node-9"
out := app.renderVMHostTable(90, 8)
if !containsString(out, "Reboot node-9?") {
t.Fatalf("confirmation prompt was clipped from full table:\n%s", out)
}
}
func TestResolveVMRebootPlaybookMissingDefaultFails(t *testing.T) {
cfg := &config.Config{PlaybookDir: t.TempDir()}
if _, err := resolveVMRebootPlaybook(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())
}
}
func TestResolveVMRebootPlaybookCustomMissingFails(t *testing.T) {
cfg := &config.Config{PlaybookDir: t.TempDir(), RebootPlaybook: "custom-reboot.yml"}
if _, err := resolveVMRebootPlaybook(cfg); err == nil {
t.Fatalf("expected missing custom reboot playbook to fail")
}
}
func TestResolveVMRebootPlaybookExistingDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "reboot.yml")
if err := os.WriteFile(path, []byte("---\n- hosts: all\n"), 0o644); err != nil {
t.Fatalf("write reboot playbook: %v", err)
}
cfg := &config.Config{PlaybookDir: dir}
playbook, err := resolveVMRebootPlaybook(cfg)
if err != nil {
t.Fatalf("resolveVMRebootPlaybook returned error: %v", err)
}
if playbook != "reboot.yml" {
t.Fatalf("playbook = %q, want reboot.yml", playbook)
}
}
func TestVMSyntheticRebootLogVisibleWithHostFilter(t *testing.T) {
m := newLogTableModel()
m.serverFilter = "pvr"
m.AddRow(vmSyntheticLogRow("pvr", "reboot.yml", "failed", "runner_failed", "Reboot", "missing playbook"))
rows := m.filteredRows()
if len(rows) != 1 {
t.Fatalf("filteredRows = %d, want synthetic pvr row visible", len(rows))
}
if rows[0].Status != "failed" || rows[0].Summary != "missing playbook" {
t.Fatalf("unexpected synthetic row: %#v", rows[0])
}
}
func TestVMPackageProgressUpdatesStateAndLogRow(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusUpdating, updates: 128, updatesTotal: 128}
_, _ = app.Update(vmOutputLineMsg{rows: []ansible.LogRow{{
Server: "node-3",
Status: "ok",
Event: "runner_ok",
Task: "Report package update progress",
Msg: `ATUI_PACKAGE_PROGRESS: {"package":"kernel-core.x86_64","index":42,"total":128,"phase":"installing"}`,
}}})
s := app.vmUpdater.hostStates["node-3"]
if s.updatesDone != 41 || s.updatesTotal != 128 || s.currentPackage != "kernel-core.x86_64" {
t.Fatalf("unexpected progress state: %+v", s)
}
rows := app.vmUpdater.vmLogTable.filteredRows()
if len(rows) != 1 || rows[0].Status != "running" || !containsString(rows[0].Summary, "42/128") {
t.Fatalf("unexpected progress log row: %#v", rows)
}
}
func TestVMPackageResultIncrementsProgress(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{{"node-3", "swarm"}}))
app.vmUpdater.hostStates["node-3"] = vmHostState{status: vmStatusUpdating, updates: 128, updatesTotal: 128, updatesDone: 41}
_, _ = app.Update(vmOutputLineMsg{rows: []ansible.LogRow{{
Server: "node-3",
Status: "changed",
Event: "runner_ok",
Task: "Update package (RedHat/Rocky/CentOS)",
Item: "kernel-core.x86_64",
}}})
s := app.vmUpdater.hostStates["node-3"]
if s.updatesDone != 42 {
t.Fatalf("updatesDone = %d, want 42", s.updatesDone)
}
}
// ---- cursor navigation bounds ----
func TestCursorNavigationStaysInBounds(t *testing.T) {
app := newTestApp(t)
app.vmUpdater = newVMUpdaterModel(makeHosts([][2]string{
{"a", "g1"}, {"b", "g1"}, {"c", "g2"},
}))
// Move up past the top.
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)
}
// 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))
}
}
// ---- openVMUpdaterScreen state preservation ----
func TestOpenVMUpdaterScreenPreservesRunState(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
app := newTestApp(t)
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
app.vmUpdater.running = true
app.vmUpdater.phase = vmPhaseChecking
// Seed some log rows.
app.vmUpdater.vmLogTable.rows = []ansible.LogRow{
{Server: "node-1", Task: "check", Summary: "ok"},
}
app.openVMUpdaterScreen()
if !app.vmUpdater.running {
t.Errorf("running flag lost after openVMUpdaterScreen")
}
if app.vmUpdater.phase != vmPhaseChecking {
t.Errorf("phase lost: got %v, want vmPhaseChecking", app.vmUpdater.phase)
}
if len(app.vmUpdater.vmLogTable.rows) != 1 {
t.Errorf("log rows lost: got %d, want 1", len(app.vmUpdater.vmLogTable.rows))
}
}
func TestOpenVMUpdaterScreenPreservesHostStates(t *testing.T) {
hosts := makeHosts([][2]string{{"node-1", "swarm"}})
app := newTestApp(t)
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
// Simulate a previous completed run so auto-start doesn't wipe the states.
app.vmUpdater.phase = vmPhaseDone
app.vmUpdater.hostStates["node-1"] = vmHostState{
status: vmStatusPending,
updates: 7,
distro: "Ubuntu",
}
app.openVMUpdaterScreen()
st := app.vmUpdater.hostStates["node-1"]
if st.status != vmStatusPending {
t.Errorf("host status lost: got %v, want vmStatusPending", st.status)
}
if st.updates != 7 {
t.Errorf("updates count lost: got %d, want 7", st.updates)
}
if st.distro != "Ubuntu" {
t.Errorf("distro lost: got %q, want 'Ubuntu'", st.distro)
}
}
func TestOpenVMUpdaterScreenAutoSelectsFirstHost(t *testing.T) {
hosts := makeHosts([][2]string{{"adguard-1", "dns"}, {"node-1", "swarm"}})
app := newTestApp(t)
app.inv = &inventory.Inventory{Hosts: hosts}
app.vmUpdater = newVMUpdaterModel(hosts)
// Set phase to done so auto-start doesn't trigger (no real ansible binary available).
app.vmUpdater.phase = vmPhaseDone
app.openVMUpdaterScreen()
if app.vmUpdater.selectedHost == "" {
t.Errorf("no host auto-selected after openVMUpdaterScreen")
}
// First host in sorted order is adguard-1 (dns < swarm).
if app.vmUpdater.selectedHost != "adguard-1" {
t.Errorf("selectedHost = %q, want 'adguard-1'", app.vmUpdater.selectedHost)
}
}
// ---- test helpers ----
func containsString(s, sub string) bool {
return len(s) > 0 && len(sub) > 0 && (s == sub || len(s) >= len(sub) && findSub(s, sub))
}
func findSub(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// newTestApp creates a minimal App suitable for unit tests.
func newTestApp(t *testing.T) *App {
t.Helper()
return &App{
inv: &inventory.Inventory{},
cfg: &config.Config{},
hist: history.New(t.TempDir()),
}
}
// renderVMHostTable renders all host names in the table output.
func TestRenderVMHostTableContainsAllHosts(t *testing.T) {
app := newTestApp(t)
app.width = 200
app.height = 50
hosts := makeHosts([][2]string{{"node-1", "swarm"}, {"node-2", "swarm"}})
app.vmUpdater = newVMUpdaterModel(hosts)
app.vmSelectHost(0)
out := app.renderVMHostTable(100, 20)
for _, h := range hosts {
if !containsString(out, h.Name) {
t.Errorf("host name %q not found in renderVMHostTable output", h.Name)
}
}
}
// ---- VM run status derivation ----
func TestVMRunStatusDerivation(t *testing.T) {
cases := []struct {
name string
unreachable int
hasErr bool
exitCode int
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"},
}
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)
}
})
}
}