menu formatting update

This commit is contained in:
David Schroeder
2026-09-22 23:04:25 -05:00
parent 224996d310
commit 6ceb98e831
7 changed files with 344 additions and 129 deletions
+14 -1
View File
@@ -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
+6 -3
View File
@@ -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
+64 -13
View File
@@ -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)
+91
View File
@@ -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"
}
+75 -111
View File
@@ -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"
)
+1 -1
View File
@@ -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'
+93
View File
@@ -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()