diff --git a/Go/main.go b/Go/main.go index e9b026a..10a642b 100644 --- a/Go/main.go +++ b/Go/main.go @@ -2,10 +2,12 @@ package main import ( "bytes" + "errors" "fmt" "os" "os/exec" "regexp" + "strconv" "strings" ) @@ -13,7 +15,7 @@ var ( tmpDir string numeric = regexp.MustCompile(`^[0-9]+$`) ) - +const version = "V0.1.2" func main() { if os.Geteuid() != 0 { fmt.Fprintf(os.Stderr, "Run this program as root, for example: sudo %s\n", os.Args[0]) @@ -36,21 +38,8 @@ func main() { mainMenu() } -// menu displays a whiptail menu and returns the selected tag. -// An empty selection means the user cancelled or pressed Esc. -func menu(title, prompt string, height, width, menuHeight int, items ...string) string { - args := []string{ - "--title", title, - "--menu", prompt, - fmt.Sprint(height), fmt.Sprint(width), fmt.Sprint(menuHeight), - } - args = append(args, items...) - - return whiptail(args...) -} - -// dialog runs whiptail and captures its result through file descriptor 3. -// The menu itself continues to use the terminal. +// whiptail runs a dialog and captures its selected value through file +// descriptor 3. The dialog itself uses the terminal. func whiptail(args ...string) string { readEnd, writeEnd, err := os.Pipe() if err != nil { @@ -66,7 +55,7 @@ func whiptail(args ...string) string { cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() - writeEnd.Close() + _ = writeEnd.Close() var result bytes.Buffer _, _ = result.ReadFrom(readEnd) @@ -76,16 +65,22 @@ func whiptail(args ...string) string { return strings.TrimSpace(result.String()) } +func menu(title, prompt string, height, width, menuHeight int, items ...string) string { + args := []string{ + "--title", title, + "--menu", prompt, + strconv.Itoa(height), + strconv.Itoa(width), + strconv.Itoa(menuHeight), + } + args = append(args, items...) + return whiptail(args...) +} + func message(title, text string) { whiptail("--title", title, "--msgbox", text, "8", "70") } -func confirm(text string) bool { - return whiptail("--title", "Confirm", "--yesno", text, "10", "70") == "" -} - -// Yes/no dialogs also return an empty string on success, so use this -// separate helper to distinguish Yes from No or Esc by the process status. func confirmAction(text string) bool { readEnd, writeEnd, err := os.Pipe() if err != nil { @@ -94,7 +89,10 @@ func confirmAction(text string) bool { defer readEnd.Close() cmd := exec.Command( - "whiptail", "--title", "Confirm", "--yesno", text, "10", "70", + "whiptail", + "--title", "Confirm", + "--yesno", text, + "10", "70", ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -102,18 +100,10 @@ func confirmAction(text string) bool { cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() - writeEnd.Close() - _, _ = readEnd.Read(make([]byte, 1)) + _ = writeEnd.Close() return err == nil } -func input(title, prompt string) (string, bool) { - value := whiptail("--title", title, "--inputbox", prompt, "9", "72") - // An empty value is valid for some prompts, so cancellation is - // determined by whiptail itself in inputValue below when needed. - return value, true -} - func inputValue(title, prompt string) (string, bool) { readEnd, writeEnd, err := os.Pipe() if err != nil { @@ -121,15 +111,20 @@ func inputValue(title, prompt string) (string, bool) { } defer readEnd.Close() - cmd := exec.Command("whiptail", "--output-fd", "3", - "--title", title, "--inputbox", prompt, "9", "72") + cmd := exec.Command( + "whiptail", + "--output-fd", "3", + "--title", title, + "--inputbox", prompt, + "9", "72", + ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() - writeEnd.Close() + _ = writeEnd.Close() var result bytes.Buffer _, _ = result.ReadFrom(readEnd) @@ -141,8 +136,10 @@ func inputValue(title, prompt string) (string, bool) { func requireCommand(name string) bool { if _, err := exec.LookPath(name); err != nil { - message("Command unavailable", - fmt.Sprintf("The command '%s' is not installed or not available on this node.", name)) + message( + "Command unavailable", + fmt.Sprintf("The command '%s' is not installed or not available on this node.", name), + ) return false } return true @@ -160,8 +157,22 @@ func askID(prompt string) (string, bool) { return value, true } +func terminalSize(name string, fallback int) int { + out, err := exec.Command("tput", name).Output() + if err != nil { + return fallback + } + + value, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil || value <= 0 { + return fallback + } + return value +} + func showOutput(title string, name string, args ...string) { outputPath := tmpDir + "/output.txt" + output, err := os.Create(outputPath) if err != nil { message("Error", "Could not create a temporary output file.") @@ -174,24 +185,113 @@ func showOutput(title string, name string, args ...string) { runErr := cmd.Run() _ = output.Close() + status := 0 + if runErr != nil { + status = 1 + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + status = exitErr.ExitCode() + } + } + data, _ := os.ReadFile(outputPath) if len(data) == 0 { - status := 0 - if runErr != nil { - status = 1 - } - data = []byte(fmt.Sprintf("Command completed with exit status %d; no output.\n", status)) - } else if runErr != nil { - data = append(data, []byte("\nCommand exited with an error.\n")...) + data = []byte(fmt.Sprintf( + "Command completed with exit status %d; no output.\n", + status, + )) + } else if status != 0 { + data = append(data, []byte(fmt.Sprintf( + "\nCommand exited with status %d.\n", + status, + ))...) } - _ = os.WriteFile(outputPath, data, 0600) - whiptail("--title", title, "--textbox", outputPath, "24", "85") + if err := os.WriteFile(outputPath, data, 0600); err != nil { + message("Error", "Could not write command output to the temporary file.") + return + } + + // Leave room around the dialog and use fallback dimensions if tput fails. + height := terminalSize("lines", 24) - 4 + width := terminalSize("cols", 80) - 4 + + // Keep dimensions within whiptail's practical limits. + if height < 12 { + height = 12 + } + if width < 50 { + width = 50 + } + + whiptail( + "--title", title, + "--textbox", outputPath, + strconv.Itoa(height), + strconv.Itoa(width), + ) +} + +func showMemory(title, id string) { + output, err := exec.Command("qm", "config", id).CombinedOutput() + if err != nil { + status := 1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + status = exitErr.ExitCode() + } + text := string(output) + if text != "" { + text += "\n" + } + text += fmt.Sprintf("Command exited with status %d.\n", status) + showText(title, text) + return + } + + var matches []string + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "memory:") { + matches = append(matches, line) + } + } + if len(matches) == 0 { + matches = []string{"No memory setting found."} + } + + showText(title, strings.Join(matches, "\n")+"\n") +} + +func showText(title, text string) { + outputPath := tmpDir + "/output.txt" + if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil { + message("Error", "Could not write command output to the temporary file.") + return + } + + height := terminalSize("lines", 24) - 4 + width := terminalSize("cols", 80) - 4 + if height < 12 { + height = 12 + } + if width < 50 { + width = 50 + } + + whiptail( + "--title", title, + "--textbox", outputPath, + strconv.Itoa(height), + strconv.Itoa(width), + ) } func vmMenu() { for { - choice := menu("Virtual Machines (QEMU)", "Choose an action:", 16, 72, 8, + choice := menu( + "Virtual Machines (QEMU)", + "Choose an action:", + 16, 72, 8, "list", "List VMs (qm list)", "config", "Show VM configuration", "memory", "Show VM memory setting", @@ -208,6 +308,7 @@ func vmMenu() { if !requireCommand("qm") { continue } + id, ok := askID("Enter the VMID:") if !ok { continue @@ -217,9 +318,12 @@ func vmMenu() { case "config": showOutput("VM "+id+" configuration", "qm", "config", id) case "memory": - showMemory("VM "+id+" memory", "qm", "config", id, "^memory:") + showMemory("VM "+id+" memory", id) case "unlock": - if confirmAction("Unlock VM " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this VM.") { + if confirmAction( + "Unlock VM " + id + "?\n\n" + + "Only do this if you have confirmed that no task is still running for this VM.", + ) { showOutput("Unlock VM "+id, "qm", "unlock", id) } } @@ -229,31 +333,12 @@ func vmMenu() { } } -func showMemory(title, command string, args ...string) { - output, err := exec.Command(command, args[:len(args)-1]...).CombinedOutput() - if err != nil { - showOutput(title, command, args[:len(args)-1]...) - return - } - - var matches []string - for _, line := range strings.Split(string(output), "\n") { - if matched, _ := regexp.MatchString(args[len(args)-1], line); matched { - matches = append(matches, line) - } - } - if len(matches) == 0 { - matches = []string{"No memory setting found."} - } - - path := tmpDir + "/output.txt" - _ = os.WriteFile(path, []byte(strings.Join(matches, "\n")+"\n"), 0600) - whiptail("--title", title, "--textbox", path, "24", "85") -} - func containerMenu() { for { - choice := menu("Containers (LXC)", "Choose an action:", 14, 72, 6, + choice := menu( + "Containers (LXC)", + "Choose an action:", + 14, 72, 6, "list", "List containers (pct list)", "config", "Show container configuration", "unlock", "Unlock a container", @@ -269,13 +354,18 @@ func containerMenu() { if !requireCommand("pct") { continue } + id, ok := askID("Enter the container ID:") if !ok { continue } + if choice == "config" { showOutput("Container "+id+" configuration", "pct", "config", id) - } else if confirmAction("Unlock container " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this container.") { + } else if confirmAction( + "Unlock container " + id + "?\n\n" + + "Only do this if you have confirmed that no task is still running for this container.", + ) { showOutput("Unlock container "+id, "pct", "unlock", id) } case "back", "": @@ -288,17 +378,25 @@ func backupMenu() { if !requireCommand("vzdump") { return } + id, ok := askID("Enter the VM or container ID to back up:") if !ok { return } - if confirmAction("Start a backup for ID " + id + " using vzdump defaults?\n\nReview your node's backup configuration and storage capacity first.") { + + if confirmAction( + "Start a backup for ID " + id + " using vzdump defaults?\n\n" + + "Review your node's backup configuration and storage capacity first.", + ) { showOutput("Backup ID "+id, "vzdump", id) } } func restoreMenu() { - choice := menu("Restore", "Choose what to restore:", 12, 72, 4, + choice := menu( + "Restore", + "Choose what to restore:", + 12, 72, 4, "vm", "Restore a QEMU VM (qmrestore)", "ct", "Restore a container (pct restore)", "back", "Back", @@ -311,17 +409,25 @@ func restoreMenu() { if !ok { return } - archive, ok := inputValue("Backup archive", "Enter the full path to the vzdump archive:") + + archive, ok := inputValue( + "Backup archive", + "Enter the full path to the vzdump archive:", + ) if !ok { return } + info, err := os.Stat(archive) if archive == "" || err != nil || !info.Mode().IsRegular() { message("Archive not found", "That file does not exist or is not a regular file.") return } - if !confirmAction("Restore '" + archive + "' as ID " + id + "?\n\nRestoring can create or overwrite resources. Confirm the target ID and archive carefully.") { + if !confirmAction( + "Restore '" + archive + "' as ID " + id + "?\n\n" + + "Restoring can create or overwrite resources. Confirm the target ID and archive carefully.", + ) { return } @@ -336,7 +442,10 @@ func restoreMenu() { func storageMenu() { for { - choice := menu("Storage / LVM", "Choose a read-only report:", 14, 72, 6, + choice := menu( + "Storage / LVM", + "Choose a read-only report:", + 14, 72, 6, "pvs", "Physical volumes (pvs)", "vgs", "Volume groups (vgs)", "lvs", "Logical volumes (lvs)", @@ -344,13 +453,17 @@ func storageMenu() { ) switch choice { - case "pvs", "vgs", "lvs": - if requireCommand(choice) { - showOutput(map[string]string{ - "pvs": "Physical volumes", - "vgs": "Volume groups", - "lvs": "Logical volumes", - }[choice], choice) + case "pvs": + if requireCommand("pvs") { + showOutput("Physical volumes", "pvs") + } + case "vgs": + if requireCommand("vgs") { + showOutput("Volume groups", "vgs") + } + case "lvs": + if requireCommand("lvs") { + showOutput("Logical volumes", "lvs") } case "back", "": return @@ -360,7 +473,10 @@ func storageMenu() { func mainMenu() { for { - choice := menu("Proxmox VE Toolkit", "Select a task:", 21, 76, 13, + choice := menu( + "Proxmox VE Toolkit "+version, + "Select a task:", + 21, 76, 13, "vms", "Virtual machines (QEMU)", "containers", "Containers (LXC)", "backup", "Back up a VM or container (vzdump)", @@ -394,7 +510,8 @@ func mainMenu() { case "storage": storageMenu() case "perf": - if requireCommand("pveperf") && confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") { + if requireCommand("pveperf") && + confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") { showOutput("Host performance", "pveperf") } case "subscription": diff --git a/bash/pve-toolkit.sh b/bash/pve-toolkit.sh index a52d9ae..cc08388 100755 --- a/bash/pve-toolkit.sh +++ b/bash/pve-toolkit.sh @@ -48,6 +48,7 @@ show_output() { shift local output="$TMPDIR_TOOLKIT/output.txt" local status=0 + local lines cols height width "$@" >"$output" 2>&1 || status=$? @@ -57,7 +58,17 @@ show_output() { printf '\nCommand exited with status %s.\n' "$status" >>"$output" fi - whiptail --title "$title" --textbox "$output" 24 85 + # Leave room around the dialog; use sensible fallback sizes if tput fails. + lines=$(tput lines 2>/dev/null || printf '24') + cols=$(tput cols 2>/dev/null || printf '80') + height=$((lines - 4)) + width=$((cols - 4)) + + # Keep dimensions within whiptail's practical limits. + (( height < 12 )) && height=12 + (( width < 50 )) && width=50 + + whiptail --title "$title" --textbox "$output" "$height" "$width" } confirm_action() { diff --git a/proxmox-toolkit b/proxmox-toolkit index cd49382..7f43b1c 100755 Binary files a/proxmox-toolkit and b/proxmox-toolkit differ