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