Checkpoint current ansibleTUI changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user