From 6ceb98e831bcb36f8d946bd128c8d11d16ec148d Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 22 Sep 2026 23:04:25 -0500 Subject: [PATCH] menu formatting update --- README.md | 15 ++- defaults.inc | 9 +- inc/orphan-disks.py | 77 +++++++++++++--- inc/ui.inc | 91 +++++++++++++++++++ proxmenu-scripts.sh | 186 +++++++++++++++----------------------- tests/test-menu-update.py | 2 +- tests/test-ui.py | 93 +++++++++++++++++++ 7 files changed, 344 insertions(+), 129 deletions(-) create mode 100644 inc/ui.inc create mode 100644 tests/test-ui.py diff --git a/README.md b/README.md index 54d7418..414354d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ its Git origin. Branch switching is refused when the installed repository has local changes or when the destination branch has commits that would be discarded. +## Menu presentation + +Menus use cyan for the selection and task prompts, muted context and navigation +help, and text labels alongside success, warning, and error colors. A `>` on the +right of an item marks a submenu. Extra context appears below the selected item. +The header stacks host details on narrow terminals and separates update status +from menu actions. Task output replaces the choices while retaining the header. + +The shared menu layout and disk report omit ANSI styling when redirected or when +`NO_COLOR` is set. Disk reports separate scan coverage from result counts, group +findings by storage and node, and explain approximate sizes without adding CSV +columns. CSV content and disk classification are unchanged by the presentation. + ## Direct actions The menu script also supports these direct actions: @@ -59,7 +72,7 @@ orphan-disks Report cluster-wide orphan candidates and unused disks (read-only) ## Orphaned and unused disks -Open **Cluster & Maintenance → Find orphaned / unused disks (read-only)**. +Open **Cluster & Maintenance → Find orphaned and unused disks [Read-only]**. The menu offers CSV export after displaying the report. Direct commands: ```bash diff --git a/defaults.inc b/defaults.inc index 35b9db1..c0cdb19 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.9.22-14' +VERS='2026.9.22-15' noupdate=' ' @@ -35,8 +35,11 @@ EXIT1() { ENTER2CONTINUE() { echo - read -r -s -p "[Press ENTER to continue]" - echo -e "\e[1A\n\e[0K\r\n" + if declare -F TAPM_UI_INIT >/dev/null; then + TAPM_UI_INIT + fi + read -r -s -p " ${TAPM_UI_ACCENT:-}[Enter]${TAPM_UI_RESET:-} Return to menu " + echo } # if [ -f /etc/apt/sources.list.d/gyptazy.list ]; then diff --git a/inc/orphan-disks.py b/inc/orphan-disks.py index 6c4bd5e..5e90047 100755 --- a/inc/orphan-disks.py +++ b/inc/orphan-disks.py @@ -5,6 +5,9 @@ import csv from datetime import datetime import json import math +import os +import shutil +import textwrap from pathlib import Path import re import subprocess @@ -119,7 +122,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api): if node.get('status') != 'online': warnings.append(f'{name}: offline; storage not scanned') continue - print(f'Scanning {name} storage...', file=sys.stderr, flush=True) + ReportDisplay(sys.stderr).line(f'Scanning {name} storage...', 'muted') try: storages = get(f'/nodes/{name}/storage') except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc: @@ -176,26 +179,68 @@ def format_size(size_bytes, approximate=False): size /= 1024 +class ReportDisplay: + """Keep redirected reports plain and wrap text before applying ANSI styling.""" + def __init__(self, stream=None): + self.stream = stream if stream is not None else sys.stdout + self.color = (self.stream.isatty() and os.environ.get('TERM', 'dumb') != 'dumb' + and 'NO_COLOR' not in os.environ) + self.width = max(20, min(100, shutil.get_terminal_size((80, 24)).columns)) + + def line(self, text='', style='', indent=0): + codes = {'bold': '1', 'muted': '90', 'accent': '96', + 'success': '32', 'warning': '33', 'error': '31'} + prefix = '\033[' + codes[style] + 'm' if self.color and style else '' + reset = '\033[0m' if prefix else '' + for line in textwrap.wrap(str(text), width=self.width - indent, + replace_whitespace=False) or ['']: + print(' ' * indent + prefix + line + reset, file=self.stream, flush=True) + + def render(rows, warnings, coverage): - print('\nCluster orphaned / unused disk report (read-only)') - print('Coverage: ' + ('INCOMPLETE / NEEDS REVIEW' if warnings else 'all discovered disk storage scanned')) - print('Candidates are not proof of safe deletion. Storage-managed volumes only; excludes backups, ISOs and passthrough disks.') - for entry in coverage: - print(' ' + entry) + display = ReportDisplay() + display.line() + display.line('Scan finished — incomplete coverage' if warnings else 'Scan finished', + 'warning' if warnings else 'success') + display.line(f'{len(coverage)} storage location(s) scanned.', 'muted') for warning in warnings: if warning.endswith(': inactive or disabled; not scanned'): continue - print('WARNING: ' + warning) + display.line('WARNING: ' + warning, 'warning') + display.line() + display.line('Results', 'bold') + counts = {status: sum(row['status'] == status for row in rows) + for status in ('Unused disk', 'Orphan candidate', 'Needs review')} + display.line(f"{counts['Unused disk']} unused disk(s) " + f"{counts['Orphan candidate']} orphan candidate(s) " + f"{counts['Needs review']} needing review", indent=2) group = None for row in sorted(rows, key=lambda r: (r['node'], r['storage'], r['volume'])): if group != (row['node'], row['storage']): group = (row['node'], row['storage']) - print(f'\n{group[0]} / {group[1]}') + display.line() + display.line(row['storage'], 'bold') + node = row['node'] + context = ('Shared storage · scanned via ' + node[len('shared (via '):-1] + if node.startswith('shared (via ') else 'Node: ' + node) + display.line(context, 'muted') + display.line() + if row['status'] == 'Unused disk': + display.line(row['detail'] + ' [Unused disk]', 'bold', indent=2) + else: + display.line('[' + row['status'] + ']', 'warning', indent=2) + display.line(row['detail'], 'muted', indent=2) + display.line('Volume: ' + row['volume'], indent=2) size = format_size(row['size_bytes'], row.get('size_is_approximate', False)) - print(f" {row['volume']} Disk size: {size} {row['status']}\n {row['detail']}") - print(f'\n{len(rows)} volume(s) reported.') + display.line('Disk size: ' + size, indent=2) + display.line() + display.line(f'{len(rows)} volume(s) reported.', 'bold') if not rows: - print('No unreferenced or unused volumes found in the successfully scanned storage.') + display.line('No unreferenced or unused volumes found in the successfully scanned storage.') + if any(row.get('size_is_approximate') for row in rows): + display.line('~ indicates an approximate size.', 'muted') + display.line('Review candidates before deletion. No disks were changed.', 'muted') + display.line('Storage-managed volumes only; excludes backups, ISOs and passthrough disks.', 'muted') def export_csv(path, rows): @@ -240,10 +285,16 @@ def main(): render(rows, warnings, coverage) destination = args.csv if args.interactive and sys.stdin.isatty() and not destination: - destination = input('\nCSV export file or folder (Enter to skip): ').strip() + display = ReportDisplay() + display.line() + display.line('CSV export file or folder:', 'accent') + display.line('Enter a path, or press Enter to skip.', 'muted') + destination = input('> ').strip() if destination: destination = export_csv(destination, rows) - print(f'CSV saved: {destination}') + display = ReportDisplay() + display.line('Report saved', 'success') + display.line(str(destination)) return 2 if warnings else 0 except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: print(f'Disk report failed: {exc}', file=sys.stderr) diff --git a/inc/ui.inc b/inc/ui.inc new file mode 100644 index 0000000..ba8028b --- /dev/null +++ b/inc/ui.inc @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Shared presentation helpers. No Proxmox operations belong in this file. + +TAPM_UI_INIT() { + TAPM_UI_RESET='' TAPM_UI_BOLD='' TAPM_UI_MUTED='' TAPM_UI_ACCENT='' + TAPM_UI_SUCCESS='' TAPM_UI_WARNING='' TAPM_UI_ERROR='' TAPM_UI_SELECTED='' + if [[ -t 1 && "${TERM:-dumb}" != dumb && -z "${NO_COLOR+x}" ]]; then + TAPM_UI_RESET=$'\e[0m' TAPM_UI_BOLD=$'\e[1m' TAPM_UI_MUTED=$'\e[90m' + TAPM_UI_ACCENT=$'\e[96m' TAPM_UI_SUCCESS=$'\e[32m' + TAPM_UI_WARNING=$'\e[33m' TAPM_UI_ERROR=$'\e[31m' + TAPM_UI_SELECTED=$'\e[1;36m' + fi + TAPM_UI_WIDTH="${COLUMNS:-80}" + if [[ -t 1 && "${TERM:-dumb}" != dumb ]]; then + TAPM_UI_WIDTH="$(tput cols 2>/dev/null)" || TAPM_UI_WIDTH="${COLUMNS:-80}" + fi + [[ "$TAPM_UI_WIDTH" =~ ^[0-9]+$ ]] || TAPM_UI_WIDTH=80 + (( TAPM_UI_WIDTH > 100 )) && TAPM_UI_WIDTH=100 + (( TAPM_UI_WIDTH < 20 )) && TAPM_UI_WIDTH=20 + return 0 +} + +TAPM_UI_PLAIN() { + # Existing menus embed ANSI colors in a few labels; styling is centralized here. + printf '%s' "$1" | sed -E $'s/\033\\[[0-9;]*[[:alpha:]]//g' +} + +TAPM_UI_TEXT() { + local style="$1" text="$2" line + while IFS= read -r line; do + printf ' %s%s%s\n' "$style" "$line" "$TAPM_UI_RESET" + done < <(printf '%s\n' "$text" | fold -s -w "$((TAPM_UI_WIDTH - 2))") +} + +TAPM_UI_RULE() { + local rule + printf -v rule '%*s' "$((TAPM_UI_WIDTH - 2))" '' + TAPM_UI_TEXT "$TAPM_UI_MUTED" "${rule// /-}" +} + +TAPM_UI_TASK() { + TAPM_UI_TEXT "$TAPM_UI_ACCENT" "Starting: $(TAPM_UI_PLAIN "$1")" + TAPM_UI_RULE + printf '\n' +} + +TAPM_UI_MENU_META() { + local title="$1" value="$2" + TAPM_UI_KIND=task + TAPM_UI_HINT='' + case "$title:$value" in + 'Main Menu:'*|'Host Setup:post_install'|'Host Setup:virtio'|\ + 'Cluster & Maintenance:services'|'TA-ProxMenu Management:branches'|\ + 'TAPM Host Configuration:customize'|'TAPM Host Configuration:vzdump'|\ + 'TAPM Host Configuration:restore'|'Configure vzdump Performance:restore') + TAPM_UI_KIND=submenu ;; + 'Select ISO Storage:'*|'Restore TAPM Host Configuration Backup:'*|\ + 'Git Branch Management:'*|'Configure vzdump Performance:'*) + TAPM_UI_KIND=choice ;; + 'Customize TAPM Host Profile:'*) + [[ "$value" == apply ]] || TAPM_UI_KIND=choice ;; + 'TA-ProxMenu Management:install_update') + # The update banner is printed after the user confirms installation. + TAPM_UI_KIND=choice ;; + esac + case "$value" in back|quit) TAPM_UI_KIND=navigation ;; esac + case "$title:$value" in + 'Cluster & Maintenance:maintenance') + TAPM_UI_HINT='Applies to this host; entering maintenance evacuates guests.' ;; + 'Cluster & Maintenance:keepalived') + TAPM_UI_HINT='Deploy or reconfigure Keepalived across cluster hosts.' ;; + 'Cluster & Maintenance:iso_nfs') + TAPM_UI_HINT='Create an LXC NFS server and register shared ISO storage.' ;; + 'Cluster & Maintenance:orphan_disks') + TAPM_UI_HINT='Read-only cluster scan with optional CSV export.' ;; + 'Host Setup:cpu') + TAPM_UI_HINT='Analyze migration compatibility; review before applying a CPU model.' ;; + 'TA-ProxMenu Management:install_update') + TAPM_UI_HINT="Update status: ${UPDATE_STATUS:-unknown}" ;; + esac +} + +TAPM_UI_MENU_ROW() { + local number="$1" label="$2" active="$3" marker=' ' style='' suffix='' + label="$(TAPM_UI_PLAIN "$label")" + [[ "$TAPM_UI_KIND" == submenu ]] && suffix=' >' + if (( active )); then + marker='>' style="$TAPM_UI_SELECTED" + fi + TAPM_UI_TEXT "$style" "$marker $number $label$suffix" +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 132d504..3f1d353 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -22,6 +22,7 @@ source /opt/idssys/ta-proxmenu/inc/cpu-compat.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc +source /opt/idssys/ta-proxmenu/inc/ui.inc ACTION_REQUESTED=0 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1 @@ -1761,69 +1762,47 @@ FORCE_UPDATE_CHECK() { MENU_HEADER() { - local header_title='TA-ProxMenu - Proxmox Setup Scripts' - local header_width=75 - local status_color='' - local status_display='' - local version_color="${idsCL[White]}" - local right_width - local spacer_width - + local status_display='' status_style='' title='TA-ProxMenu' + local version="${VERS}" spacing host + host="$(hostname -s)" + TAPM_UI_INIT TAPM_LOAD_HEADER_INFO - - # Reuse a settled update result instead of running Git checks on every - # arrow-key redraw. Continue refreshing only while the worker is pending. - if [[ "$UPDATE_STATUS" == 'unknown' || "$UPDATE_STATUS" == 'checking' ]]; then + if [[ "$UPDATE_STATUS" == unknown || "$UPDATE_STATUS" == checking ]]; then LOAD_UPDATE_STATUS fi case "$UPDATE_STATUS" in - behind) - status_display='** UPDATE AVAILABLE **' - status_color="${idsCL[LightYellow]}" - version_color="${idsCL[LightYellow]}" - ;; - ahead) - status_display='** LOCAL COMMITS **' - status_color="${idsCL[LightYellow]}" - version_color="${idsCL[LightYellow]}" - ;; - diverged) - status_display='** BRANCH DIVERGED **' - status_color="${idsCL[LightRed]}" - version_color="${idsCL[LightRed]}" - ;; - dirty) - status_display='** LOCAL CHANGES **' - status_color="${idsCL[LightYellow]}" - version_color="${idsCL[LightYellow]}" - ;; - checking) - status_display='(checking for updates)' - status_color="${idsCL[LightCyan]}" - version_color="${idsCL[LightCyan]}" - ;; + behind) status_display='Update available'; status_style="$TAPM_UI_WARNING" ;; + ahead) status_display='Local commits ahead of origin'; status_style="$TAPM_UI_WARNING" ;; + diverged) status_display='Branch diverged'; status_style="$TAPM_UI_ERROR" ;; + dirty) status_display='Local changes'; status_style="$TAPM_UI_WARNING" ;; + checking) status_display='Checking for updates'; status_style="$TAPM_UI_MUTED" ;; + current) status_display='Up to date'; status_style="$TAPM_UI_MUTED" ;; + unknown) status_display='Update status unavailable'; status_style="$TAPM_UI_MUTED" ;; esac - - right_width="${#VERS}" - if [[ -n "$status_display" ]]; then - right_width=$((right_width + ${#status_display} + 2)) + [[ -t 1 && "${TERM:-dumb}" != dumb ]] && clear + printf '\n' + spacing=$((TAPM_UI_WIDTH - 2 - ${#title} - ${#version})) + if (( spacing >= 2 )); then + printf ' %s%s%s%*s%s\n' "$TAPM_UI_BOLD" "$title" "$TAPM_UI_RESET" "$spacing" '' "$version" + else + TAPM_UI_TEXT "$TAPM_UI_BOLD" "$title" + TAPM_UI_TEXT "$TAPM_UI_MUTED" "$version" fi - spacer_width=$((header_width - 1 - ${#header_title} - right_width)) - ((spacer_width < 2)) && spacer_width=2 - - clear - echo - printf ' %b%s%b%*s' \ - "${idsCL[Green]}" "$header_title" "${idsCL[Default]}" "$spacer_width" '' - if [[ -n "$status_display" ]]; then - printf '%b%s%b ' "$status_color" "$status_display" "${idsCL[Default]}" + TAPM_UI_TEXT "$TAPM_UI_MUTED" 'Proxmox Setup Scripts' + TAPM_UI_RULE + # Stack context on narrow terminals rather than squeezing both columns. + if (( TAPM_UI_WIDTH >= 74 && ${#host} <= 20 && ${#TAPM_HEADER_PVE_VERSION} <= 20 && ${#TAPM_HEADER_CLUSTER} <= 25 && ${#RNIP} <= 25 )); then + printf ' %sHostname%s %-20s %sIP Address%s %s\n' "$TAPM_UI_MUTED" "$TAPM_UI_RESET" "$host" "$TAPM_UI_MUTED" "$TAPM_UI_RESET" "${RNIP:-Unavailable}" + printf ' %sProxmox VE%s %-20s %sCluster%s %s\n' "$TAPM_UI_MUTED" "$TAPM_UI_RESET" "$TAPM_HEADER_PVE_VERSION" "$TAPM_UI_MUTED" "$TAPM_UI_RESET" "$TAPM_HEADER_CLUSTER" + else + TAPM_UI_TEXT '' "Hostname $host" + TAPM_UI_TEXT '' "IP Address ${RNIP:-Unavailable}" + TAPM_UI_TEXT '' "Proxmox VE $TAPM_HEADER_PVE_VERSION" + TAPM_UI_TEXT '' "Cluster $TAPM_HEADER_CLUSTER" fi - printf '%b%s%b\n' "$version_color" "$VERS" "${idsCL[Default]}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" - echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" - echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" - echo -e " Proxmox VE: ${idsCL[Cyan]}${TAPM_HEADER_PVE_VERSION}${idsCL[Default]} Cluster: ${idsCL[Cyan]}${TAPM_HEADER_CLUSTER}${idsCL[Default]}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" + TAPM_UI_RULE + [[ -z "$status_display" ]] || TAPM_UI_TEXT "$status_style" "$status_display" + return 0 } SELECT_MENU() { @@ -1845,28 +1824,28 @@ SELECT_MENU() { while true; do MENU_HEADER echo - echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" + TAPM_UI_TEXT "$TAPM_UI_BOLD" "$title" echo - for index in "${!labels_ref[@]}"; do - if (( index == selected )); then - printf '\e[7m %d %-64s\e[0m\n' "$((index + 1))" "${labels_ref[$index]}" - else - printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}" - fi + TAPM_UI_MENU_META "$title" "${values_ref[$index]}" + TAPM_UI_MENU_ROW "$((index + 1))" "${labels_ref[$index]}" "$((index == selected))" done - - echo - if (( allow_back == 1 && allow_space == 1 )); then - echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select ←/Esc/B Back Q Quit" - elif (( allow_back == 1 )); then - echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" - elif (( allow_space == 1 )); then - echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select Q Quit" - else - echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" + TAPM_UI_MENU_META "$title" "${values_ref[$selected]}" + if [[ -n "$TAPM_UI_HINT" ]]; then + echo + TAPM_UI_TEXT "$TAPM_UI_MUTED" "$TAPM_UI_HINT" fi - echo + echo + local quick_max=${#values_ref[@]} + (( quick_max > 9 )) && quick_max=9 + TAPM_UI_TEXT "$TAPM_UI_MUTED" "↑/↓ Move Enter Select 1-${quick_max} Quick select" + (( allow_space == 0 )) || TAPM_UI_TEXT "$TAPM_UI_MUTED" 'Space Toggle' + if (( allow_back )); then + TAPM_UI_TEXT "$TAPM_UI_MUTED" '←/Esc/B Back Q Quit' + else + TAPM_UI_TEXT "$TAPM_UI_MUTED" 'Q Quit' + fi + echo MENU_INPUT="" MENU_SELECTED_INDEX="$selected" @@ -1943,9 +1922,9 @@ SELECT_MENU() { # Leave only the header above the selected action's output. MENU_HEADER echo - if [[ "$MENU_SELECTION" != "back" && "$MENU_SELECTION" != "quit" && "$MENU_INPUT" != "space" ]]; then - printf '%b Starting: %b%b\n\n' \ - "${idsCL[LightCyan]}" "${labels_ref[$selected]}" "${idsCL[Default]}" + TAPM_UI_MENU_META "$title" "$MENU_SELECTION" + if [[ "$TAPM_UI_KIND" == task && "$MENU_INPUT" != space ]]; then + TAPM_UI_TASK "${labels_ref[$selected]}" fi return 0 } @@ -2360,7 +2339,7 @@ HOST_SETUP_MENU() { values=("post_install") TAPM_POST_PROXMENUX_DETECTED && labels[0]="TAPM Host Configuration — ProxMenux migration recommended" - labels+=("Analyze VM CPU compatibility for HA and workload mobility") + labels+=("Analyze VM CPU migration compatibility") values+=("cpu") # Discover ISO storage only after the VirtIO submenu is selected. @@ -2415,7 +2394,7 @@ MONITORING_MENU() { fi if systemctl is-active --quiet ITSPlatform; then - labels+=("ConnectWise RMM agent (installed)") + labels+=("ConnectWise RMM agent (running)") else labels+=($'Install \e[36mConnectWise RMM\e[39m agent') fi @@ -2435,7 +2414,7 @@ MONITORING_MENU() { fi if systemctl is-active --quiet 'connectwise*'; then - labels+=("ScreenConnect agent (installed)") + labels+=("ScreenConnect agent (running)") else labels+=($'Install \e[36mScreenConnect\e[39m agent') fi @@ -2486,17 +2465,17 @@ CLUSTER_MENU() { maintenance_status=$? fi if (( maintenance_status == 0 )); then - labels=("Take this host out of maintenance mode") + labels=("Exit maintenance mode on this host") else - labels=("Put this host into maintenance mode and evacuate guests") + labels=("Enter maintenance mode and evacuate guests") fi labels+=("Proxmox service recovery") dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && - labels+=("Deploy/reconfigure Keepalived (installed locally)") || + labels+=("Deploy or reconfigure Keepalived (installed locally)") || labels+=("Deploy Keepalived on all cluster hosts") - labels+=("Create shared ISO storage using an LXC NFS server") - labels+=("Find orphaned / unused disks (read-only)") + labels+=("Create shared ISO storage") + labels+=("Find orphaned and unused disks [Read-only]") SELECT_MENU "Cluster & Maintenance" labels values case "$MENU_SELECTION" in @@ -2689,6 +2668,7 @@ BRANCH_MANAGEMENT_MENU() { } TAPM_INSTALL_MENU_UPDATE() { + TAPM_UI_TASK "Update TA-ProxMenu" if ! /opt/idssys/ta-proxmenu/run.sh update; then printf '\nUpdate reported failures; review the node results above. TAPM will reload the locally installed version.\n' ENTER2CONTINUE @@ -2700,39 +2680,23 @@ TAPM_INSTALL_MENU_UPDATE() { UTILITIES_MENU() { local -a labels - local -a values=("install_update" "check_update" "branches" "about") + local -a values=() local choice local last_checked while true; do LOAD_UPDATE_STATUS - case "$UPDATE_STATUS" in - behind) - labels=("Install available update") - ;; - current) - labels=("TA-ProxMenu is current") - ;; - ahead) - labels=("Local branch is ahead of origin") - ;; - diverged) - labels=("Local branch has diverged from origin") - ;; - dirty) - labels=("Local repository has uncommitted changes") - ;; - checking) - labels=("Update check in progress") - ;; - *) - labels=("Update status unavailable") - ;; - esac + if [[ "$UPDATE_STATUS" == behind ]]; then + labels=("Install available update") + values=("install_update" "check_update" "branches" "about") + else + labels=() + values=("check_update" "branches" "about") + fi last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)" labels+=( - "Check again now "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m' - "Git branch management" + "Check for updates "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m' + "Switch Git branch" "Version and installation information" ) diff --git a/tests/test-menu-update.py b/tests/test-menu-update.py index 6b65c24..1827bcb 100644 --- a/tests/test-menu-update.py +++ b/tests/test-menu-update.py @@ -25,7 +25,7 @@ fi echo reload >> "$TEST_LOG" ''') launcher.chmod(0o700) - script = 'ENTER2CONTINUE() { echo pause >> "$TEST_LOG"; }\n' + script = 'TAPM_UI_TASK() { :; }\nENTER2CONTINUE() { echo pause >> "$TEST_LOG"; }\n' script += 'TAPM_INSTALL_MENU_UPDATE() {' + function.replace( '/opt/idssys/ta-proxmenu/run.sh', '"' + str(launcher) + '"') + '\n}\n' script += 'TAPM_INSTALL_MENU_UPDATE\necho stale-menu >> "$TEST_LOG"\n' diff --git a/tests/test-ui.py b/tests/test-ui.py new file mode 100644 index 0000000..a5d5af9 --- /dev/null +++ b/tests/test-ui.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Presentation checks runnable without a Proxmox host.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('disks', ROOT / 'inc/orphan-disks.py') +disks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(disks) + + +class UITests(unittest.TestCase): + def shell(self, script, width=80): + result = subprocess.run(['bash', '-c', 'source "$UI_FILE"\nTAPM_UI_INIT\n' + script], + capture_output=True, text=True, + env=dict(os.environ, UI_FILE=str(ROOT / 'inc/ui.inc'), + COLUMNS=str(width), TERM='xterm', NO_COLOR='1')) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, '') + self.assertNotIn('\x1b', result.stdout) + return result.stdout + + def test_plain_wrapped_menu_and_task(self): + output = self.shell(''' +TAPM_UI_RULE +TAPM_UI_MENU_META 'Cluster & Maintenance' orphan_disks +TAPM_UI_MENU_ROW 5 $'Find \\e[36morphaned\\e[39m and unused disks [Read-only]' 1 +TAPM_UI_TASK 'Find orphaned and unused disks' +''', width=40) + self.assertIn('> 5', output) + self.assertIn('Starting:', output) + self.assertTrue(all(len(line) <= 40 for line in output.splitlines())) + + def test_submenus_and_choices_do_not_start_tasks(self): + cases = [('Main Menu', 'cluster', 'submenu'), + ('Cluster & Maintenance', 'services', 'submenu'), + ('Select ISO Storage', 'local', 'choice'), + ('Customize TAPM Host Profile', 'utilities', 'choice'), + ('Customize TAPM Host Profile', 'apply', 'task'), + ('Cluster & Maintenance', 'orphan_disks', 'task'), + ('Cluster & Maintenance', 'back', 'navigation')] + for title, value, expected in cases: + with self.subTest(title=title, value=value): + output = self.shell(f"TAPM_UI_MENU_META '{title}' '{value}'\nprintf '%s' \"$TAPM_UI_KIND\"") + self.assertEqual(output, expected) + + def test_header_widths(self): + source = (ROOT / 'proxmenu-scripts.sh').read_text() + header = 'MENU_HEADER() {' + source.split('MENU_HEADER() {', 1)[1].split('\nSELECT_MENU()', 1)[0] + for width in (40, 80): + with self.subTest(width=width): + output = self.shell(''' +TAPM_LOAD_HEADER_INFO() { TAPM_HEADER_PVE_VERSION=9.2.20; TAPM_HEADER_CLUSTER=Home-Cluster; } +LOAD_UPDATE_STATUS() { :; } +hostname() { printf ids-vms2; } +UPDATE_STATUS=current VERS=2026.9.22-15 RNIP=10.10.2.12 +''' + header + '\nMENU_HEADER', width=width) + self.assertIn('TA-ProxMenu', output) + self.assertIn('Home-Cluster', output) + self.assertIn('Up to date', output) + self.assertTrue(all(len(line) <= width for line in output.splitlines())) + + def test_report_is_plain_when_redirected(self): + rows = [dict(node='shared (via ids-vms1)', storage='iSCSI-Datastore2', + volume='iSCSI-Datastore2:vm-139-disk-0.qcow2', + size_bytes=25 * 1024 ** 3, size_is_approximate=True, + status='Unused disk', detail='Webserver-Node1 (VM 139)')] + output = io.StringIO() + with patch.dict(os.environ, {'COLUMNS': '52'}), contextlib.redirect_stdout(output): + disks.render(rows, ['ids-vms3: offline; storage not scanned'], ['one store']) + text = output.getvalue() + self.assertNotIn('\x1b', text) + self.assertIn('Scan finished — incomplete coverage', text) + self.assertIn('Webserver-Node1 (VM 139)', text) + self.assertIn('~25.00 GiB', text) + self.assertTrue(all(len(line) <= 52 for line in text.splitlines())) + + def test_no_color_on_terminal_when_requested(self): + output = io.StringIO() + output.isatty = lambda: True + with patch.dict(os.environ, {'TERM': 'xterm', 'NO_COLOR': '1'}): + disks.ReportDisplay(output).line('Results', 'bold') + self.assertNotIn('\x1b', output.getvalue()) + + +if __name__ == '__main__': + unittest.main()