menu formatting update
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user