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 local changes or when the destination branch has commits that would be
discarded. 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 ## Direct actions
The menu script also supports these 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 ## 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: The menu offers CSV export after displaying the report. Direct commands:
```bash ```bash
+6 -3
View File
@@ -3,7 +3,7 @@
action="${1:-}" action="${1:-}"
FOLDER='/opt/idssys/ta-proxmenu' FOLDER='/opt/idssys/ta-proxmenu'
VERS='2026.9.22-14' VERS='2026.9.22-15'
noupdate=' ' noupdate=' '
@@ -35,8 +35,11 @@ EXIT1() {
ENTER2CONTINUE() { ENTER2CONTINUE() {
echo echo
read -r -s -p "[Press ENTER to continue]" if declare -F TAPM_UI_INIT >/dev/null; then
echo -e "\e[1A\n\e[0K\r\n" 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 # if [ -f /etc/apt/sources.list.d/gyptazy.list ]; then
+64 -13
View File
@@ -5,6 +5,9 @@ import csv
from datetime import datetime from datetime import datetime
import json import json
import math import math
import os
import shutil
import textwrap
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
@@ -119,7 +122,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api):
if node.get('status') != 'online': if node.get('status') != 'online':
warnings.append(f'{name}: offline; storage not scanned') warnings.append(f'{name}: offline; storage not scanned')
continue continue
print(f'Scanning {name} storage...', file=sys.stderr, flush=True) ReportDisplay(sys.stderr).line(f'Scanning {name} storage...', 'muted')
try: try:
storages = get(f'/nodes/{name}/storage') storages = get(f'/nodes/{name}/storage')
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc: except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc:
@@ -176,26 +179,68 @@ def format_size(size_bytes, approximate=False):
size /= 1024 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): def render(rows, warnings, coverage):
print('\nCluster orphaned / unused disk report (read-only)') display = ReportDisplay()
print('Coverage: ' + ('INCOMPLETE / NEEDS REVIEW' if warnings else 'all discovered disk storage scanned')) display.line()
print('Candidates are not proof of safe deletion. Storage-managed volumes only; excludes backups, ISOs and passthrough disks.') display.line('Scan finished — incomplete coverage' if warnings else 'Scan finished',
for entry in coverage: 'warning' if warnings else 'success')
print(' ' + entry) display.line(f'{len(coverage)} storage location(s) scanned.', 'muted')
for warning in warnings: for warning in warnings:
if warning.endswith(': inactive or disabled; not scanned'): if warning.endswith(': inactive or disabled; not scanned'):
continue 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 group = None
for row in sorted(rows, key=lambda r: (r['node'], r['storage'], r['volume'])): for row in sorted(rows, key=lambda r: (r['node'], r['storage'], r['volume'])):
if group != (row['node'], row['storage']): if group != (row['node'], row['storage']):
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)) size = format_size(row['size_bytes'], row.get('size_is_approximate', False))
print(f" {row['volume']} Disk size: {size} {row['status']}\n {row['detail']}") display.line('Disk size: ' + size, indent=2)
print(f'\n{len(rows)} volume(s) reported.') display.line()
display.line(f'{len(rows)} volume(s) reported.', 'bold')
if not rows: 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): def export_csv(path, rows):
@@ -240,10 +285,16 @@ def main():
render(rows, warnings, coverage) render(rows, warnings, coverage)
destination = args.csv destination = args.csv
if args.interactive and sys.stdin.isatty() and not destination: 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: if destination:
destination = export_csv(destination, rows) 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 return 2 if warnings else 0
except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as exc:
print(f'Disk report failed: {exc}', file=sys.stderr) 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"
}
+71 -107
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-iso-nfs-lxc.sh
source /opt/idssys/ta-proxmenu/inc/deploy-pulse-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/virtio-helpers.inc
source /opt/idssys/ta-proxmenu/inc/ui.inc
ACTION_REQUESTED=0 ACTION_REQUESTED=0
[[ -n "${action:-}" ]] && ACTION_REQUESTED=1 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1
@@ -1761,69 +1762,47 @@ FORCE_UPDATE_CHECK() {
MENU_HEADER() { MENU_HEADER() {
local header_title='TA-ProxMenu - Proxmox Setup Scripts' local status_display='' status_style='' title='TA-ProxMenu'
local header_width=75 local version="${VERS}" spacing host
local status_color='' host="$(hostname -s)"
local status_display='' TAPM_UI_INIT
local version_color="${idsCL[White]}"
local right_width
local spacer_width
TAPM_LOAD_HEADER_INFO TAPM_LOAD_HEADER_INFO
if [[ "$UPDATE_STATUS" == unknown || "$UPDATE_STATUS" == checking ]]; then
# 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
LOAD_UPDATE_STATUS LOAD_UPDATE_STATUS
fi fi
case "$UPDATE_STATUS" in case "$UPDATE_STATUS" in
behind) behind) status_display='Update available'; status_style="$TAPM_UI_WARNING" ;;
status_display='** UPDATE AVAILABLE **' ahead) status_display='Local commits ahead of origin'; status_style="$TAPM_UI_WARNING" ;;
status_color="${idsCL[LightYellow]}" diverged) status_display='Branch diverged'; status_style="$TAPM_UI_ERROR" ;;
version_color="${idsCL[LightYellow]}" dirty) status_display='Local changes'; status_style="$TAPM_UI_WARNING" ;;
;; checking) status_display='Checking for updates'; status_style="$TAPM_UI_MUTED" ;;
ahead) current) status_display='Up to date'; status_style="$TAPM_UI_MUTED" ;;
status_display='** LOCAL COMMITS **' unknown) status_display='Update status unavailable'; status_style="$TAPM_UI_MUTED" ;;
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]}"
;;
esac esac
[[ -t 1 && "${TERM:-dumb}" != dumb ]] && clear
right_width="${#VERS}" printf '\n'
if [[ -n "$status_display" ]]; then spacing=$((TAPM_UI_WIDTH - 2 - ${#title} - ${#version}))
right_width=$((right_width + ${#status_display} + 2)) 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 fi
spacer_width=$((header_width - 1 - ${#header_title} - right_width)) TAPM_UI_TEXT "$TAPM_UI_MUTED" 'Proxmox Setup Scripts'
((spacer_width < 2)) && spacer_width=2 TAPM_UI_RULE
# Stack context on narrow terminals rather than squeezing both columns.
clear if (( TAPM_UI_WIDTH >= 74 && ${#host} <= 20 && ${#TAPM_HEADER_PVE_VERSION} <= 20 && ${#TAPM_HEADER_CLUSTER} <= 25 && ${#RNIP} <= 25 )); then
echo 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 ' %b%s%b%*s' \ 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"
"${idsCL[Green]}" "$header_title" "${idsCL[Default]}" "$spacer_width" '' else
if [[ -n "$status_display" ]]; then TAPM_UI_TEXT '' "Hostname $host"
printf '%b%s%b ' "$status_color" "$status_display" "${idsCL[Default]}" TAPM_UI_TEXT '' "IP Address ${RNIP:-Unavailable}"
TAPM_UI_TEXT '' "Proxmox VE $TAPM_HEADER_PVE_VERSION"
TAPM_UI_TEXT '' "Cluster $TAPM_HEADER_CLUSTER"
fi fi
printf '%b%s%b\n' "$version_color" "$VERS" "${idsCL[Default]}" TAPM_UI_RULE
echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" [[ -z "$status_display" ]] || TAPM_UI_TEXT "$status_style" "$status_display"
echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" return 0
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]}"
} }
SELECT_MENU() { SELECT_MENU() {
@@ -1845,26 +1824,26 @@ SELECT_MENU() {
while true; do while true; do
MENU_HEADER MENU_HEADER
echo echo
echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" TAPM_UI_TEXT "$TAPM_UI_BOLD" "$title"
echo echo
for index in "${!labels_ref[@]}"; do for index in "${!labels_ref[@]}"; do
if (( index == selected )); then TAPM_UI_MENU_META "$title" "${values_ref[$index]}"
printf '\e[7m %d %-64s\e[0m\n' "$((index + 1))" "${labels_ref[$index]}" TAPM_UI_MENU_ROW "$((index + 1))" "${labels_ref[$index]}" "$((index == selected))"
else
printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}"
fi
done done
TAPM_UI_MENU_META "$title" "${values_ref[$selected]}"
if [[ -n "$TAPM_UI_HINT" ]]; then
echo echo
if (( allow_back == 1 && allow_space == 1 )); then TAPM_UI_TEXT "$TAPM_UI_MUTED" "$TAPM_UI_HINT"
echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select ←/Esc/B Back Q Quit" fi
elif (( allow_back == 1 )); then echo
echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" local quick_max=${#values_ref[@]}
elif (( allow_space == 1 )); then (( quick_max > 9 )) && quick_max=9
echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select Q Quit" 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 else
echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" TAPM_UI_TEXT "$TAPM_UI_MUTED" 'Q Quit'
fi fi
echo echo
@@ -1943,9 +1922,9 @@ SELECT_MENU() {
# Leave only the header above the selected action's output. # Leave only the header above the selected action's output.
MENU_HEADER MENU_HEADER
echo echo
if [[ "$MENU_SELECTION" != "back" && "$MENU_SELECTION" != "quit" && "$MENU_INPUT" != "space" ]]; then TAPM_UI_MENU_META "$title" "$MENU_SELECTION"
printf '%b Starting: %b%b\n\n' \ if [[ "$TAPM_UI_KIND" == task && "$MENU_INPUT" != space ]]; then
"${idsCL[LightCyan]}" "${labels_ref[$selected]}" "${idsCL[Default]}" TAPM_UI_TASK "${labels_ref[$selected]}"
fi fi
return 0 return 0
} }
@@ -2360,7 +2339,7 @@ HOST_SETUP_MENU() {
values=("post_install") values=("post_install")
TAPM_POST_PROXMENUX_DETECTED && TAPM_POST_PROXMENUX_DETECTED &&
labels[0]="TAPM Host Configuration — ProxMenux migration recommended" 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") values+=("cpu")
# Discover ISO storage only after the VirtIO submenu is selected. # Discover ISO storage only after the VirtIO submenu is selected.
@@ -2415,7 +2394,7 @@ MONITORING_MENU() {
fi fi
if systemctl is-active --quiet ITSPlatform; then if systemctl is-active --quiet ITSPlatform; then
labels+=("ConnectWise RMM agent (installed)") labels+=("ConnectWise RMM agent (running)")
else else
labels+=($'Install \e[36mConnectWise RMM\e[39m agent') labels+=($'Install \e[36mConnectWise RMM\e[39m agent')
fi fi
@@ -2435,7 +2414,7 @@ MONITORING_MENU() {
fi fi
if systemctl is-active --quiet 'connectwise*'; then if systemctl is-active --quiet 'connectwise*'; then
labels+=("ScreenConnect agent (installed)") labels+=("ScreenConnect agent (running)")
else else
labels+=($'Install \e[36mScreenConnect\e[39m agent') labels+=($'Install \e[36mScreenConnect\e[39m agent')
fi fi
@@ -2486,17 +2465,17 @@ CLUSTER_MENU() {
maintenance_status=$? maintenance_status=$?
fi fi
if (( maintenance_status == 0 )); then if (( maintenance_status == 0 )); then
labels=("Take this host out of maintenance mode") labels=("Exit maintenance mode on this host")
else else
labels=("Put this host into maintenance mode and evacuate guests") labels=("Enter maintenance mode and evacuate guests")
fi fi
labels+=("Proxmox service recovery") labels+=("Proxmox service recovery")
dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && 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+=("Deploy Keepalived on all cluster hosts")
labels+=("Create shared ISO storage using an LXC NFS server") labels+=("Create shared ISO storage")
labels+=("Find orphaned / unused disks (read-only)") labels+=("Find orphaned and unused disks [Read-only]")
SELECT_MENU "Cluster & Maintenance" labels values SELECT_MENU "Cluster & Maintenance" labels values
case "$MENU_SELECTION" in case "$MENU_SELECTION" in
@@ -2689,6 +2668,7 @@ BRANCH_MANAGEMENT_MENU() {
} }
TAPM_INSTALL_MENU_UPDATE() { TAPM_INSTALL_MENU_UPDATE() {
TAPM_UI_TASK "Update TA-ProxMenu"
if ! /opt/idssys/ta-proxmenu/run.sh update; then 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' printf '\nUpdate reported failures; review the node results above. TAPM will reload the locally installed version.\n'
ENTER2CONTINUE ENTER2CONTINUE
@@ -2700,39 +2680,23 @@ TAPM_INSTALL_MENU_UPDATE() {
UTILITIES_MENU() { UTILITIES_MENU() {
local -a labels local -a labels
local -a values=("install_update" "check_update" "branches" "about") local -a values=()
local choice local choice
local last_checked local last_checked
while true; do while true; do
LOAD_UPDATE_STATUS LOAD_UPDATE_STATUS
case "$UPDATE_STATUS" in if [[ "$UPDATE_STATUS" == behind ]]; then
behind)
labels=("Install available update") labels=("Install available update")
;; values=("install_update" "check_update" "branches" "about")
current) else
labels=("TA-ProxMenu is current") labels=()
;; values=("check_update" "branches" "about")
ahead) fi
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
last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)" last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)"
labels+=( labels+=(
"Check again now "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m' "Check for updates "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m'
"Git branch management" "Switch Git branch"
"Version and installation information" "Version and installation information"
) )
+1 -1
View File
@@ -25,7 +25,7 @@ fi
echo reload >> "$TEST_LOG" echo reload >> "$TEST_LOG"
''') ''')
launcher.chmod(0o700) 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( script += 'TAPM_INSTALL_MENU_UPDATE() {' + function.replace(
'/opt/idssys/ta-proxmenu/run.sh', '"' + str(launcher) + '"') + '\n}\n' '/opt/idssys/ta-proxmenu/run.sh', '"' + str(launcher) + '"') + '\n}\n'
script += 'TAPM_INSTALL_MENU_UPDATE\necho stale-menu >> "$TEST_LOG"\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()