diff --git a/.github/scripts/tests/test_menu_search.py b/.github/scripts/tests/test_menu_search.py new file mode 100644 index 00000000..4555de1a --- /dev/null +++ b/.github/scripts/tests/test_menu_search.py @@ -0,0 +1,155 @@ +"""The search of the main menu finds an option of ProxMenux, an OCI application +or a Helper-Script by its name or by related words, and opens it where it +lives: the scripts it launches are the ones the menus launch.""" + +import importlib.util +import json +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ROOT / "scripts" + + +def load(): + spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + # The repository in the place of the installed copy. + module.BASE, module.SCRIPTS = ROOT, SCRIPTS + module.OCI_INDEX = ROOT / "oci/catalog/index.json" + module.HELPERS_CACHE = ROOT / "json/helpers_cache.json" + module._translations = {} + return module + + +class IndexTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8")) + cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8")) + + def test_every_entry_opens_a_script_that_exists(self): + missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()}) + self.assertEqual(missing, []) + + def test_every_title_is_a_text_the_menus_already_translate(self): + names = {"ZimaOS", "Synology DSM"} + unknown = [item["title"] for item in self.index + if item["title"] not in self.spanish and item["title"] not in names] + self.assertEqual(unknown, []) + self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], []) + + def test_every_script_a_menu_launches_can_be_found(self): + indexed = {item["script"] for item in self.index} + launched = set() + for menu in sorted((SCRIPTS / "menus").glob("*.sh")): + if menu.name in ("main_menu.sh", "lxc_menu.sh"): + continue + launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text())) + launched -= {"menus/main_menu.sh"} + # Opened from their own menu, which is what the index points at. + launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"} + self.assertEqual(sorted(launched - indexed), []) + + def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self): + menu = (SCRIPTS / "menus/main_menu.sh").read_text() + self.assertIn('search "$(translate "Search options and applications")"', menu) + self.assertIn('search_menu.py" --handoff "$handoff"', menu) + self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu) + self.assertIn('exec bash "${target[@]}"', menu) + + def test_an_option_inside_a_menu_names_one_that_menu_has(self): + for item in self.index: + argument = item.get("argument") + if not argument: + continue + script = (SCRIPTS / item["script"]).read_text() + self.assertIn("PRESELECTED_OPTION", script, item["script"]) + if not argument.isdigit(): + self.assertIn(argument, script, f"{item['script']}: {argument}") + + +class CatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.search = load() + cls.items = cls.search.catalog(refresh=False) + + def found(self, query): + return self.search.search(self.items, query) + + def test_the_three_sources_are_in_the_catalog(self): + self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES)) + + def test_proxmenux_comes_first_then_oci_then_helper_scripts(self): + sources = [item["source"] for item in self.found("samba")] + self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index)) + self.assertEqual(sources[0], "ProxMenux") + + def test_a_name_is_found_by_the_start_of_any_of_its_words(self): + self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")]) + self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex"))) + + def test_accents_and_case_do_not_matter(self): + self.assertEqual([item["title"] for item in self.found("NVIDIA")], + [item["title"] for item in self.found("nvidia")]) + + def test_related_words_find_an_option(self): + self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage"))) + self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban"))) + + def test_a_match_by_category_comes_after_the_names(self): + matches = self.found("vpn") + self.assertTrue(any(item["title"] == "Tailscale" for item in matches)) + by_category = False + for item in matches: + words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"])) + in_name = any(word.startswith("vpn") for word in words) + self.assertFalse(in_name and by_category) + by_category = by_category or not in_name + + def test_an_application_is_not_found_by_what_its_description_mentions(self): + titles = [item["title"] for item in self.found("plex")] + self.assertNotIn("Jellyfin", titles) + self.assertTrue(all("plex" in title.casefold() for title in titles)) + + def test_nothing_matches_nothing(self): + self.assertEqual(self.found("zzqqxx"), []) + self.assertEqual(self.found(" "), []) + + def test_a_sub_menu_is_handed_back_to_the_main_menu(self): + by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"} + self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"]) + self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"]) + + def test_an_option_inside_a_menu_is_given_to_that_menu(self): + zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"]) + self.assertFalse(zfs["handoff"]) + language = next(item for item in self.items if item["action"][-1] == "change_language") + self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh")) + self.assertTrue(language["handoff"]) + + +class OpenedInPlaceTests(unittest.TestCase): + def test_an_option_of_the_post_install_comes_selected(self): + script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text() + self.assertIn('PRESELECTED_OPTION="${1:-}"', script) + self.assertIn('--default-item "${preselected_item:-1}"', script) + + def test_a_utility_comes_selected(self): + script = (SCRIPTS / "utilities/system_utils.sh").read_text() + self.assertIn('PRESELECTED_UTILITY="${1:-}"', script) + + def test_a_helper_script_is_opened_on_its_own(self): + script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text() + self.assertIn('run_script_by_slug "$1"', script) + + def test_an_oci_application_opens_its_page(self): + cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text() + self.assertIn('subparsers.add_parser("open"', cli) + + +if __name__ == "__main__": + unittest.main() diff --git a/lang/es.json b/lang/es.json index d3b3039c..71513ac4 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2912,6 +2912,7 @@ "Importing disk": "Importando disco", "Importing:": "Importador:", "Improve log rotation and limit log size to save space and extend disk life": "Mejorar la rotación de logs y limitar su tamaño para ahorrar espacio y alargar la vida del disco", + "In an LXC": "En un LXC", "In the loader interface, follow the instructions to select your Synology model": "En la interfaz del cargador, siga las instrucciones para seleccionar su modelo de Synology", "In use": "En uso", "In use by VM/LXC config": "En uso por la configuración de VM/LXC", @@ -3481,6 +3482,7 @@ "Listing relevant CT users and their mapped UID/GID on host...": "Listado de usuarios CT relevantes y su UID/GID asignado en el host...", "Load and verify the WireGuard module on the Proxmox host": "Cargar y verificar el módulo WireGuard en el host Proxmox", "Loading modules...": "Cargando módulos...", + "Loading the Helper-Scripts catalog...": "Cargando el catálogo de Helper-Scripts...", "Loading the host kernel module:": "Carga el módulo del kernel host:", "Local Disk Manager - Proxmox Host": "Administrador de discos locales - Proxmox Host", "Local Disk Storages": "Almacenamientos en disco local", @@ -3552,6 +3554,7 @@ "Machine type: q35": "Tipo de máquina: q35", "Machine: q35": "Máquina: q35", "Main endpoint not yet defined": "Punto final principal aún no definido", + "Main menu": "Menú principal", "Major version differs:": "La versión principal difiere:", "Make sure IOMMU is properly enabled and the system has been rebooted after activation.": "Asegúrese de que IOMMU esté habilitado correctamente y que el sistema se haya reiniciado después de la activación.", "Make sure there are no critical services running as they will be interrupted. Ensure your server can be safely rebooted.": "Asegúrese de que no haya servicios críticos en ejecución, ya que se interrumpirán. Asegúrese de que su servidor pueda reiniciarse de forma segura.", @@ -3888,6 +3891,7 @@ "Name of this wallabag instance, shown in the interface and in 2FA codes": "Nombre de esta instancia de wallabag, mostrado en la interfaz y en los códigos 2FA", "Name or address NetBox will be opened at (for example netbox.example.com)": "Nombre o dirección en la que se abrirá NetBox (por ejemplo netbox.example.com)", "Name or part of the description of the application": "Nombre o parte de la descripción de la aplicación", + "Name or related words: zima, plex, post…": "Nombre o palabras relacionadas: zima, plex, post…", "Name:": "Nombre:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", "Needed:": "Necesario:", @@ -4166,6 +4170,7 @@ "No language selected.": "No se seleccionó ningún idioma.", "No local disk storage configured.": "No hay almacenamiento en disco local configurado.", "No main sources.list present (skipped)": "No hay sources.list principal (omitido)", + "No matches for:": "No hay coincidencias para:", "No matching PVE vzdump jobs were found for the PBS backend.": "No se encontraron trabajos PVE vzdump coincidentes para el backend de PBS.", "No matching PVE vzdump jobs were found for the local archive backend.": "No se encontraron trabajos PVE vzdump coincidentes para el backend del archivo local.", "No mount point was specified.": "No se especificó ningún punto de montaje.", @@ -4196,6 +4201,7 @@ "No recent Samba servers found.": "No se encontraron servidores Samba recientes.", "No registered OCI containers are available for selection on this host.": "No hay contenedores OCI registrados disponibles para seleccionar en este host.", "No restored OCI application is waiting to be recovered.": "No hay ninguna aplicación OCI restaurada pendiente de recuperar.", + "No results": "Sin resultados", "No routing information found.": "No se encontró información de enrutamiento.", "No scheduled backup jobs configured.": "No se han configurado trabajos de backup programados.", "No scheduled backup jobs found.": "No se encontraron trabajos de backup programados.", @@ -4287,6 +4293,7 @@ "Number of CPU cores (default: 2)": "Número de núcleos de CPU (predeterminado: 2)", "Nzbget is a usenet downloader, written in C++ and designed with performance in mind to achieve maximum download speed by using very little system resources.": "Nzbget es un descargador de Usenet escrito en C++ y diseñado pensando en el rendimiento, para lograr la máxima velocidad de descarga con muy pocos recursos del sistema.", "Nzbhydra2 is a meta search application for NZB indexers, the spiritual successor to NZBmegasearcH, and an evolution of the original application NZBHydra.": "Nzbhydra2 es una aplicación de metabúsqueda para indexadores NZB, sucesora espiritual de NZBmegasearcH y evolución de la aplicación original NZBHydra.", + "OCI application": "Aplicación OCI", "OCI container, configuration kept. Remove its GPU from OCI manager Apps: CT": "Contenedor OCI, configuración conservada. Elimina su GPU desde OCI manager Apps: CT", "OCI containers keep their configuration: they are stopped and their Start on boot is disabled. Their GPU is removed from OCI manager Apps.": "Los contenedores OCI conservan su configuración: se detienen y se desactiva su inicio automático. Su GPU se elimina desde OCI manager Apps.", "OCI containers require Proxmox VE 9.1 or later.": "Los contenedores OCI requieren Proxmox VE 9.1 o posterior.", @@ -4331,6 +4338,7 @@ "On a privileged CT the mount options carry the only permissions.": "En un CT privilegiado, las opciones de montaje tienen los únicos permisos.", "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.": "En algunos sistemas, al iniciar la máquina virtual, el host puede ralentizarse durante varios minutos hasta que se estabilice o se congele por completo.", "On the Borg server, append the following line to:": "en el servidor Borg, agregue la siguiente línea a:", + "On the host": "En el host", "Once finished, re-run 'PVE 8 to 9 check' to verify that all issues are resolved \n before executing the PVE 8 → PVE 9 upgrade.": "Una vez terminado, vuelva a ejecutar la 'verificación PVE 8 a 9' para verificar que todos los problemas estén resueltos. \n antes de ejecutar la actualización PVE 8 → PVE 9.", "Once finished, re-run the script 'PVE 8 to 9 check' to verify that all issues are resolved \n before rebooting.": "Una vez terminado, vuelva a ejecutar el script 'PVE 8 to 9 check' para verificar que todos los problemas estén resueltos. \n antes de reiniciar.", "Once finished, re-run the script 'PVE 8 to 9 check' to verify that all issues.": "Una vez terminado, vuelva a ejecutar el script 'PVE 8 to 9 check' para verificar que todos los problemas.", @@ -5190,6 +5198,7 @@ "Restoring the stack records...": "Restaurando los registros de la pila...", "Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:", "Results will be saved to:": "Los resultados se guardarán en:", + "Results:": "Resultados:", "Retention": "Retención", "Retention policy (prune-backups syntax).": "Política de retención (sintaxis de prune-backups).", "RetroArch is a frontend for emulators, game engines and media players. It enables you to run classic games on a wide range of computers and consoles through its slick graphical interface.": "RetroArch es un frontend para emuladores, motores de juego y reproductores multimedia. Permite ejecutar juegos clásicos en una gran variedad de ordenadores y consolas con una interfaz gráfica muy cuidada.", @@ -5381,8 +5390,10 @@ "Scripts in": "Scripts en", "ScummVM is a program which allows you to run certain classic graphical adventure and role-playing games, provided you already have their data files. The clever part about this: ScummVM just replaces the executables shipped with the games, allowing you to play them on systems for which they were never designed! ScummVM is a complete rewrite of these games' executables and is not an emulator.": "ScummVM es un programa que permite ejecutar ciertas aventuras gráficas y juegos de rol clásicos, siempre que ya tengas sus archivos de datos. Lo ingenioso es que ScummVM sustituye los ejecutables de los juegos, de modo que puedes jugarlos en sistemas para los que nunca se diseñaron. ScummVM es una reescritura completa de esos ejecutables y no es un emulador.", "Sealskin is a self-hosted, client-server platform that enables users to run powerful, containerized desktop applications streamed directly to a web browser. It uses a browser extension to intercept user actions—such as clicking a link or downloading a file and redirects them to a secure, isolated application environment running on a remote server.": "Sealskin es una plataforma cliente-servidor autoalojada que permite ejecutar potentes aplicaciones de escritorio en contenedores transmitidas directamente al navegador. Usa una extensión del navegador para interceptar acciones del usuario, como hacer clic en un enlace o descargar un archivo, y redirigirlas a un entorno de aplicación seguro y aislado en un servidor remoto.", + "Search": "Buscar", "Search Results for:": "Resultados de búsqueda para:", "Search applications": "Buscar aplicaciones", + "Search options and applications": "Buscar opciones y aplicaciones", "Search results for:": "Resultados de la búsqueda para:", "Search/Filter Scripts": "Buscar/filtrar scripts", "Searchable document archive with OCR": "Archivo de documentos con búsqueda y OCR", @@ -5520,6 +5531,7 @@ "Select the GPU to pass through to the VM:": "Seleccione la GPU para pasar a la VM:", "Select the GPU(s) to add to LXC": "Seleccione las GPU para agregar a LXC", "Select the LXC container:": "Seleccione el contenedor LXC:", + "Select the LXC. OCI containers are not included.": "Selecciona el LXC. Los contenedores OCI no se incluyen.", "Select the Linux distribution to install:": "Seleccione la distribución de Linux para instalar:", "Select the NAS system to install:": "Seleccione el sistema NAS a instalar:", "Select the NVIDIA driver version to install:": "Seleccione la versión del controlador NVIDIA para instalar:", @@ -6092,7 +6104,10 @@ "The Immich startup was modified or cannot be reproduced": "La startup Immich fue modificada o no se puede reproducir", "The LXC changed during verification; nothing was adopted": "El LXC cambió durante la verificación; no se ha incorporado nada", "The LXC changed while reviewing; nothing was adopted": "El LXC cambió durante la revisión; no se ha incorporado nada", + "The LXC could not be started.": "No se pudo arrancar el LXC.", + "The LXC has no Bash to run this script with. The addon was not run.": "El LXC no tiene Bash disponible para ejecutar este script. No se ha ejecutado el addon.", "The LXC has stopped": "El LXC ha parado", + "The LXC is stopped. Do you want to start it to run the addon?": "El LXC está detenido. ¿Quieres arrancarlo para ejecutar el addon?", "The Lounge starts in public mode: anyone who reaches the address opens the client without logging in, and the IRC networks added are lost when the session ends.": "The Lounge arranca en modo público: cualquiera que llegue a la dirección abre el cliente sin iniciar sesión, y las redes IRC añadidas se pierden al terminar la sesión.", "The MAC address of the container cannot be kept": "La dirección MAC del contenedor no se puede mantener", "The NVIDIA Container Toolkit repository definition was empty.": "La definición del repositorio de NVIDIA Container Toolkit estaba vacía.", @@ -6599,6 +6614,7 @@ "Then run this option again:": "Luego ejecute esta opción nuevamente:", "Then update /etc/fstab on the host with the same options.": "Luego actualice /etc/fstab en el host con las mismas opciones.", "There are extra disks or bind mounts outside the journal; the rootfs is not replaced": "Hay discos o bind mounts adicionales fuera del diario de la operación; el rootfs no se reemplaza", + "There are no LXC containers available. OCI containers are not included.": "No hay contenedores LXC disponibles. Los contenedores OCI no se incluyen.", "There is no temporary container of this operation to keep the current disks": "No hay contenedor temporal de esta operación para mantener los discos actuales", "There is no verified backup; a modified container is not touched": "No hay backup verificado; un contenedor modificado no se toca", "These Proxmox resources were added outside ProxMenux:": "Se añadieron estos recursos en Proxmox fuera de ProxMenux:", @@ -6621,6 +6637,7 @@ "This application cannot be recovered yet:": "Esta aplicación todavía no se puede recuperar:", "This application cannot be recovered yet; nothing was changed:": "Esta aplicación todavía no se puede recuperar; no se ha cambiado nada:", "This application is not an Immich installed by ProxMenux": "Esta aplicación no es un Immich instalado por ProxMenux", + "This application is not in the OCI catalog.": "Esta aplicación no está en el catálogo OCI.", "This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?": "Esta aplicación está bajo vigilancia: se reinicia automáticamente cuando falla. ¿Desactivar la vigilancia?", "This archive does not contain a recognized backup layout.": "Este archivo no contiene un diseño de backup reconocido.", "This backup does not contain any restorable paths.": "Este backup no contiene rutas restaurables.", @@ -7305,6 +7322,7 @@ "Where do you want to mount inside container?": "¿Dónde desea montar dentro del contenedor?", "Where do you want to mount the NFS export?": "¿Dónde desea montar la exportación NFS?", "Where do you want to mount the Samba share?": "¿Dónde quieres montar el recurso compartido Samba?", + "Where do you want to run this addon?": "¿Dónde quieres ejecutar este addon?", "Where is the OVA/OVF file located?": "¿Dónde se encuentra el archivo OVA/OVF?", "Where to mount inside container?": "¿Dónde montarlo dentro del contenedor?", "Where to store": "Dónde almacenar", diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index a3fb9963..10be0374 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -27,7 +27,7 @@ from .installer import ( run_remote_install, run_remote_rclone_mount, ) -from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, UserCancelled, interactive_ui +from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, UserCancelled, interactive_ui, menu_size PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -66,7 +66,7 @@ def is_tested(item: dict[str, Any]) -> bool: or bool((item.get("community_tested") or {}).get("by"))) -MENU_SIZE = (22, 75, 15) +MENU_SIZE = menu_size() MULTI_LABEL = r"\Z4MULTI\Zn" TESTED_LABEL = r"\Z2✓\Zn" @@ -620,6 +620,8 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets") show_parser = subparsers.add_parser("show", help="Show the summary of a template") show_parser.add_argument("app") + open_parser = subparsers.add_parser("open", help="Open the page of an application in the menu") + open_parser.add_argument("app") install_parser = subparsers.add_parser("install", help="Configure and install an application") install_parser.add_argument("app") install_parser.add_argument("--host", default="auto", help="'auto'/'local', or root@IP for development") @@ -682,6 +684,18 @@ def main(argv: list[str] | None = None) -> int: if args.command == "show": print(_template_summary_text(catalog.compose(args.app))) return 0 + if args.command == "open": + item = next((item for item in _installable(catalog.load_index()["applications"]) + if item["id"] == args.app), None) + ui = interactive_ui() + if item is None: + ui.message(translate("This application is not in the OCI catalog.")) + return 1 + try: + _app_detail(catalog, ui, item) + except UserCancelled: + pass + return 0 if args.command == "install": template = catalog.compose(args.app) if not template["compatibility"]["automatic_install_candidate"]: diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 6ead495f..41e9928b 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -12,6 +12,7 @@ import tempfile from . import images from .i18n import N_, source_text, translate +from .ui import menu_size STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating': N_('updating'), 'recovering': N_('recovering'), 'installing': N_('installing'), @@ -268,7 +269,7 @@ def _interactive_management(project, ui): else: line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" options.append((str(r['vmid']), f"{line:<72}")) - selection = ui.choose(header, options, size=(22, 75, 15), colors=True, + selection = ui.choose(header, options, size=menu_size(), colors=True, title=translate('Manage installed OCI applications')) if selection is None: return diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index 7bb9a0f7..cbd2d4e9 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -194,6 +194,13 @@ SUMMARY_WIDTH = 78 DETAIL_WINDOW_WIDTH = 90 +def menu_size() -> tuple[int, int, int]: + """Height, width and rows of a list of the menu: as tall as the main menu + of ProxMenux, cut to the terminal.""" + height = max(10, min(24, shutil.get_terminal_size((100, 30)).lines - 2)) + return height, 75, height - 8 + + @dataclass class DialogUI: """dialog widgets with the ProxMenux backtitle, used for every menu shown diff --git a/scripts/help_info_menu.sh b/scripts/help_info_menu.sh index 873ac07a..770400ca 100644 --- a/scripts/help_info_menu.sh +++ b/scripts/help_info_menu.sh @@ -1077,7 +1077,25 @@ show_tools_commands() { # =============================================================== # Help and Info Menu # =============================================================== +# A section given as argument, as the menu search does, is opened first. +PRESELECTED_OPTION="${1:-}" while true; do + if [[ "$PRESELECTED_OPTION" =~ ^[1-9]$ ]]; then + OPTION="$PRESELECTED_OPTION" + PRESELECTED_OPTION="" + case $OPTION in + 1) show_system_commands ;; + 2) show_vm_ct_commands ;; + 3) show_storage_commands ;; + 4) show_network_commands ;; + 5) show_update_commands ;; + 6) show_gpu_commands ;; + 7) show_zfs_commands ;; + 8) show_backup_commands ;; + 9) show_tools_commands ;; + esac + continue + fi OPTION=$(dialog --stdout \ --title "$(translate 'Help and Info')" \ --menu "$(translate 'Select a category of useful commands:')" 20 70 9 \ diff --git a/scripts/menus/config_menu.sh b/scripts/menus/config_menu.sh index 1cf1cdef..87ba734a 100644 --- a/scripts/menus/config_menu.sh +++ b/scripts/menus/config_menu.sh @@ -613,8 +613,18 @@ show_config_menu() { menu_options+=("$option_num" "$(translate "Return to Main Menu")") option_actions[$option_num]="return_main" + # An action given as argument, as the menu search does, is opened + # first; the menu follows. + OPTION="" + if [[ -n "$PRESELECTED_OPTION" ]]; then + local index + for index in "${!option_actions[@]}"; do + [[ "${option_actions[$index]}" == "$PRESELECTED_OPTION" ]] && OPTION="$index" + done + PRESELECTED_OPTION="" + fi # Show menu - OPTION=$(dialog --clear --backtitle "$BACKTITLE" \ + [[ -n "$OPTION" ]] || OPTION=$(dialog --clear --backtitle "$BACKTITLE" \ --title "$(translate "Configuration Menu")" \ --menu "$(translate "Select an option:")" 20 70 10 \ "${menu_options[@]}" 3>&1 1>&2 2>&3) @@ -854,4 +864,5 @@ uninstall_proxmenu() { # ========================================================== # Main execution +PRESELECTED_OPTION="${1:-}" show_config_menu diff --git a/scripts/menus/create_vm_menu.sh b/scripts/menus/create_vm_menu.sh index 11279ccb..6c081a27 100644 --- a/scripts/menus/create_vm_menu.sh +++ b/scripts/menus/create_vm_menu.sh @@ -130,27 +130,35 @@ function start_vm_configuration() { +# A system type given as argument, as the menu search does, is opened first; +# the menu follows. +PRESELECTED_OPTION="${1:-}" while true; do VM_STORAGE_IOMMU_PENDING_REBOOT=0 WIZARD_CONFLICT_POLICY="" WIZARD_CONFLICT_SCOPE="" export WIZARD_CONFLICT_POLICY WIZARD_CONFLICT_SCOPE - OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \ - --title "$(translate "Select System Type")" \ - --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \ - 1 "$(translate "Create VM System NAS")" \ - 2 "$(translate "Create VM System Windows")" \ - 3 "$(translate "Create VM System Linux")" \ - "" "" \ - "" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn" \ - "" "" \ - 4 "$(translate "Create VM System macOS (OSX-PROXMOX)")" \ - 5 "$(translate "Create VM System Others (based Linux)")" \ - "" "" \ - 6 "$(translate "Return to Main Menu")" \ - 3>&1 1>&2 2>&3) + if [[ "$PRESELECTED_OPTION" =~ ^[1-5]$ ]]; then + OS_TYPE="$PRESELECTED_OPTION" + PRESELECTED_OPTION="" + else + OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \ + --title "$(translate "Select System Type")" \ + --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \ + 1 "$(translate "Create VM System NAS")" \ + 2 "$(translate "Create VM System Windows")" \ + 3 "$(translate "Create VM System Linux")" \ + "" "" \ + "" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn" \ + "" "" \ + 4 "$(translate "Create VM System macOS (OSX-PROXMOX)")" \ + 5 "$(translate "Create VM System Others (based Linux)")" \ + "" "" \ + 6 "$(translate "Return to Main Menu")" \ + 3>&1 1>&2 2>&3) - [[ $? -ne 0 || "$OS_TYPE" == "6" ]] && exec bash "$MENU_REPO/main_menu.sh" + [[ $? -ne 0 || "$OS_TYPE" == "6" ]] && exec bash "$MENU_REPO/main_menu.sh" + fi case "$OS_TYPE" in 1) diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh index 4b735973..82e0391f 100644 --- a/scripts/menus/main_menu.sh +++ b/scripts/menus/main_menu.sh @@ -52,12 +52,20 @@ show_menu() { while true; do - local menu_title="Main ProxMenux" + local menu_title="Main menu" + + # As tall as its options need, cut to the terminal. + local height=24 limit=$(( $(tput lines 2>/dev/null || echo 24) - 2 )) + (( height > limit )) && height=$limit + local rows=$(( height - 8 )) + (( rows < 3 )) && rows=3 dialog --clear \ --backtitle "ProxMenux" \ --title "$(translate "$menu_title")" \ - --menu "\n$(translate "Select an option:")" 22 70 14 \ + --menu "$(translate "Select an option:")" "$height" 75 "$rows" \ + search "$(translate "Search options and applications")" \ + "" "" \ 1 "$(translate "Settings post-install Proxmox")" \ 2 "$(translate "Hardware: GPUs and Coral-TPU")" \ 3 "$(translate "Create VM from template or script")" \ @@ -85,6 +93,21 @@ show_menu() { OPTION=$(<"$TEMP_FILE") case $OPTION in + search) + # A result that is a sub-menu comes back as its path: it is + # opened from here, as the options below are. + local handoff + local -a target=() + handoff=$(mktemp) + python3 "$LOCAL_SCRIPTS/menus/search_menu.py" --handoff "$handoff" + mapfile -t target < "$handoff" + rm -f "$handoff" + if [[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]; then + rm -f "$TEMP_FILE" + exec bash "${target[@]}" + fi + ;; + "") ;; 1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;; 2) exec bash "$LOCAL_SCRIPTS/menus/hw_grafics_menu.sh" ;; 3) exec bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh" ;; diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index d26a22fa..447583e7 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -288,6 +288,7 @@ show_menu() { declare -A script_commands local counter=1 + local preselected="" # Sprint 12C: re-evaluate available updates on every loop so the # entry vanishes after the user has applied everything (and the @@ -317,6 +318,7 @@ show_menu() { formatted_item=$(format_menu_item "$translated_name" "$source") menu_items+=("$counter" "$formatted_item") script_commands["$counter"]="$command" + [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter" ((counter++)) done @@ -332,6 +334,7 @@ show_menu() { formatted_item=$(format_menu_item "$translated_name" "$source") menu_items+=("$counter" "$formatted_item") script_commands["$counter"]="$command" + [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter" ((counter++)) done @@ -340,15 +343,23 @@ show_menu() { menu_items+=("0" "$(translate "Return to Main Menu")") - exec 3>&1 - script_selection=$(dialog --clear --colors \ - --backtitle "ProxMenux" \ - --title "$(translate "Post-Installation Scripts")" \ - --menu "\n$(translate "Select a post-installation script:")\n" \ - 24 84 16 \ - "${menu_items[@]}" 2>&1 1>&3) - exit_status=$? - exec 3>&- + # An option given as argument, as the menu search does, is opened + # first; the menu follows. + if [[ -n "$preselected" ]]; then + script_selection="$preselected" + exit_status=0 + PRESELECTED_OPTION="" + else + exec 3>&1 + script_selection=$(dialog --clear --colors \ + --backtitle "ProxMenux" \ + --title "$(translate "Post-Installation Scripts")" \ + --menu "\n$(translate "Select a post-installation script:")\n" \ + 24 84 16 \ + "${menu_items[@]}" 2>&1 1>&3) + exit_status=$? + exec 3>&- + fi if [ $exit_status -ne 0 ] || [ "$script_selection" = "0" ]; then @@ -372,4 +383,5 @@ show_menu() { # ========================================================== +PRESELECTED_OPTION="${1:-}" show_menu diff --git a/scripts/menus/network_menu.sh b/scripts/menus/network_menu.sh index 8b4bfa79..0c2bb8a9 100644 --- a/scripts/menus/network_menu.sh +++ b/scripts/menus/network_menu.sh @@ -1237,6 +1237,7 @@ show_menu() { declare -A script_commands local counter=1 + local preselected="" for script in "${PROXMENUX_SCRIPTS[@]}"; do IFS='|' read -r name source command <<< "$script" @@ -1246,6 +1247,7 @@ show_menu() { formatted_item=$(format_menu_item "$translated_name" "$source") menu_items+=("$counter" "$formatted_item") script_commands["$counter"]="$command" + [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter" ((counter++)) done @@ -1261,6 +1263,7 @@ show_menu() { formatted_item=$(format_menu_item "$translated_name" "$source") menu_items+=("$counter" "$formatted_item") script_commands["$counter"]="$command" + [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter" ((counter++)) done @@ -1269,15 +1272,23 @@ show_menu() { menu_items+=("0" "$(translate "Return to Main Menu")") - exec 3>&1 - script_selection=$(dialog --clear --colors \ - --backtitle "ProxMenux" \ - --title "$(translate "Network Management")" \ - --menu "\n$(translate "Select a network management option:")\n" \ - 28 82 19 \ - "${menu_items[@]}" 2>&1 1>&3) - exit_status=$? - exec 3>&- + # An option given as argument, as the menu search does, is opened + # first; the menu follows. + if [[ -n "$preselected" ]]; then + script_selection="$preselected" + exit_status=0 + PRESELECTED_OPTION="" + else + exec 3>&1 + script_selection=$(dialog --clear --colors \ + --backtitle "ProxMenux" \ + --title "$(translate "Network Management")" \ + --menu "\n$(translate "Select a network management option:")\n" \ + 28 82 19 \ + "${menu_items[@]}" 2>&1 1>&3) + exit_status=$? + exec 3>&- + fi if [ $exit_status -ne 0 ] || [ "$script_selection" = "0" ]; then @@ -1301,4 +1312,5 @@ show_menu() { # ========================================================== +PRESELECTED_OPTION="${1:-}" show_menu diff --git a/scripts/menus/search_index.json b/scripts/menus/search_index.json new file mode 100644 index 00000000..749caef2 --- /dev/null +++ b/scripts/menus/search_index.json @@ -0,0 +1,563 @@ +[ + { + "title": "Settings post-install Proxmox", + "menu": "Settings post-install Proxmox", + "script": "menus/menu_post_install.sh", + "keywords": "" + }, + { + "title": "Hardware: GPUs and Coral-TPU", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "menus/hw_grafics_menu.sh", + "keywords": "" + }, + { + "title": "Create VM from template or script", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "" + }, + { + "title": "Disk Manager", + "menu": "Disk Manager", + "script": "menus/storage_menu.sh", + "keywords": "" + }, + { + "title": "Storage & Share Manager", + "menu": "Storage & Share Manager", + "script": "menus/share_menu.sh", + "keywords": "" + }, + { + "title": "Proxmox VE Helper Scripts", + "menu": "Proxmox VE Helper Scripts", + "script": "menus/menu_Helper_Scripts.sh", + "keywords": "" + }, + { + "title": "OCI manager Apps (beta)", + "menu": "OCI manager Apps (beta)", + "script": "oci/oci_manager_apps.sh", + "keywords": "docker container image contenedor imagen apps" + }, + { + "title": "Network Management", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red vpn bridge nic" + }, + { + "title": "Security", + "menu": "Security", + "script": "menus/security_menu.sh", + "keywords": "security seguridad firewall" + }, + { + "title": "Utilities and Tools", + "menu": "Utilities and Tools", + "script": "menus/utilities_menu.sh", + "keywords": "" + }, + { + "title": "Host Backup & Restore", + "menu": "Host Backup & Restore", + "script": "backup_restore/backup_host.sh", + "keywords": "backup restore copia respaldo restaurar pbs borg" + }, + { + "title": "Help and Info Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "" + }, + { + "title": "Settings", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "settings ajustes configuracion" + }, + { + "title": "Automated post-installation script", + "menu": "Settings post-install Proxmox", + "script": "menus/menu_post_install.sh", + "keywords": "post postinstall optimizations optimizaciones auto", + "argument": "Automated post-installation script" + }, + { + "title": "Customizable post-installation script", + "menu": "Settings post-install Proxmox", + "script": "post_install/customizable_post_install.sh", + "keywords": "post postinstall optimizations optimizaciones custom log2ram" + }, + { + "title": "Uninstall optimizations", + "menu": "Settings post-install Proxmox", + "script": "post_install/uninstall-tools.sh", + "keywords": "post uninstall desinstalar" + }, + { + "title": "Install/Update NVIDIA Drivers (Host + LXC)", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/nvidia_installer.sh", + "keywords": "gpu nvidia driver" + }, + { + "title": "Install/Update Coral TPU on Host", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/install_coral.sh", + "keywords": "coral tpu usb" + }, + { + "title": "Add GPU to LXC (Intel | AMD | NVIDIA)", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/add_gpu_lxc.sh", + "keywords": "gpu passthrough lxc igpu" + }, + { + "title": "Add Coral TPU to LXC", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/install_coral_lxc.sh", + "keywords": "coral tpu lxc usb" + }, + { + "title": "Add GPU to VM (Intel | AMD | NVIDIA)", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/add_gpu_vm.sh", + "keywords": "gpu passthrough vm" + }, + { + "title": "Switch GPU Mode (VM <-> LXC)", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/switch_gpu_mode.sh", + "keywords": "gpu mode modo" + }, + { + "title": "Manual CLI Guide (GPU/TPU)", + "menu": "Hardware: GPUs and Coral-TPU", + "script": "gpu_tpu/gpu-tpu-manual-guide.sh", + "keywords": "gpu tpu guide guia" + }, + { + "title": "Create VM System NAS", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "vm virtual nas", + "argument": "1" + }, + { + "title": "Create VM System Windows", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "vm virtual windows", + "argument": "2" + }, + { + "title": "Create VM System Linux", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "vm virtual linux", + "argument": "3" + }, + { + "title": "Create VM System macOS (OSX-PROXMOX)", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "vm virtual macos osx", + "argument": "4" + }, + { + "title": "Create VM System Others (based Linux)", + "menu": "Create VM from template or script", + "script": "menus/create_vm_menu.sh", + "keywords": "vm virtual", + "argument": "5" + }, + { + "title": "ZimaOS", + "menu": "Create VM from template or script", + "script": "vm/zimaos.sh", + "keywords": "zima nas vm" + }, + { + "title": "Synology DSM", + "menu": "Create VM from template or script", + "script": "vm/synology.sh", + "keywords": "synology dsm nas loader vm" + }, + { + "title": "Import Disk to VM", + "menu": "Disk Manager", + "script": "storage/disk-passthrough.sh", + "keywords": "disk disco passthrough" + }, + { + "title": "Import Disk Image to VM", + "menu": "Disk Manager", + "script": "storage/import-disk-image.sh", + "keywords": "disk disco import image imagen" + }, + { + "title": "Add Controller or NVMe PCIe to VM", + "menu": "Disk Manager", + "script": "storage/add_controller_nvme_vm.sh", + "keywords": "nvme pcie controller passthrough" + }, + { + "title": "Import Disk to LXC", + "menu": "Disk Manager", + "script": "storage/disk-passthrough_ct.sh", + "keywords": "disk disco passthrough lxc" + }, + { + "title": "Format / Wipe Physical Disk (Safe)", + "menu": "Disk Manager", + "script": "storage/format-disk.sh", + "keywords": "disk disco format formatear wipe" + }, + { + "title": "SMART Disk Health & Test", + "menu": "Disk Manager", + "script": "storage/smart-disk-test.sh", + "keywords": "smart disk disco health salud" + }, + { + "title": "Manual CLI Guide (Disk and Storage Manager)", + "menu": "Disk Manager", + "script": "storage/disk-storage-manual-guide.sh", + "keywords": "disk storage guide guia" + }, + { + "title": "Add NFS share as Proxmox Storage", + "menu": "Storage & Share Manager", + "script": "share/nfs_host.sh", + "keywords": "nfs storage almacenamiento mount montar" + }, + { + "title": "Add Samba share as Proxmox Storage", + "menu": "Storage & Share Manager", + "script": "share/samba_host.sh", + "keywords": "samba smb cifs storage almacenamiento mount montar" + }, + { + "title": "Add iSCSI Target as Proxmox Storage", + "menu": "Storage & Share Manager", + "script": "share/iscsi_host.sh", + "keywords": "iscsi storage almacenamiento" + }, + { + "title": "Add Local Disk as Proxmox Storage", + "menu": "Storage & Share Manager", + "script": "share/disk_host.sh", + "keywords": "disk disco local storage almacenamiento" + }, + { + "title": "Create Shared Directory on Host", + "menu": "Storage & Share Manager", + "script": "share/local-shared-manager.sh", + "keywords": "directory directorio carpeta share compartir" + }, + { + "title": "Configure LXC Mount Points (Host ↔ Container)", + "menu": "Storage & Share Manager", + "script": "share/lxc-mount-manager_minimal.sh", + "keywords": "mount montaje host container contenedor bind" + }, + { + "title": "Configure NFS Client in LXC (only privileged)", + "menu": "Storage & Share Manager", + "script": "share/nfs_client.sh", + "keywords": "nfs share lxc" + }, + { + "title": "Configure Samba Client in LXC (only privileged)", + "menu": "Storage & Share Manager", + "script": "share/samba_client.sh", + "keywords": "smb samba cifs share lxc" + }, + { + "title": "Configure NFS Server in LXC (only privileged)", + "menu": "Storage & Share Manager", + "script": "share/nfs_lxc_server.sh", + "keywords": "nfs share lxc" + }, + { + "title": "Configure Samba Server in LXC (only privileged)", + "menu": "Storage & Share Manager", + "script": "share/samba_lxc_server.sh", + "keywords": "smb samba cifs share lxc" + }, + { + "title": "Help & Info (commands)", + "menu": "Storage & Share Manager", + "script": "share/commands_share.sh", + "keywords": "help ayuda commands comandos share" + }, + { + "title": "Real-time network usage (iftop)", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red iftop traffic trafico", + "argument": "Real-time network usage (iftop)" + }, + { + "title": "Network monitoring tool (iptraf-ng)", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red iptraf traffic trafico", + "argument": "Network monitoring tool (iptraf-ng)" + }, + { + "title": "Bandwidth test (iperf3)", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red iperf3 speed velocidad", + "argument": "Bandwidth test (iperf3)" + }, + { + "title": "Show Routing Table", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red route rutas", + "argument": "Show Routing Table" + }, + { + "title": "Test Connectivity", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red ping conectividad", + "argument": "Test Connectivity" + }, + { + "title": "Advanced Diagnostics", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red diagnostics diagnostico", + "argument": "Advanced Diagnostics" + }, + { + "title": "Analyze Bridge Configuration", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red bridge vmbr puente", + "argument": "Analyze Bridge Configuration" + }, + { + "title": "Analyze Network Configuration", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red interfaces config", + "argument": "Analyze Network Configuration" + }, + { + "title": "Setup Persistent Network Names", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red nic names nombres interfaces", + "argument": "Setup Persistent Network Names" + }, + { + "title": "Restart Network Service", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red restart reiniciar red", + "argument": "Restart Network Service" + }, + { + "title": "Show Network Config File", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red interfaces config", + "argument": "Show Network Config File" + }, + { + "title": "Create Network Backup", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red backup red", + "argument": "Create Network Backup" + }, + { + "title": "Restore Network Backup", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red restore restaurar red", + "argument": "Restore Network Backup" + }, + { + "title": "Disable NIC Offloading (Intel e1000e)", + "menu": "Network Management", + "script": "menus/network_menu.sh", + "keywords": "network red intel e1000e offloading nic", + "argument": "Disable NIC Offloading (Intel e1000e)" + }, + { + "title": "Fail2Ban - Intrusion Prevention", + "menu": "Security", + "script": "security/fail2ban_installer.sh", + "keywords": "fail2ban security seguridad ssh" + }, + { + "title": "Lynis - Security Audit", + "menu": "Security", + "script": "security/lynis_installer.sh", + "keywords": "lynis audit auditoria security seguridad" + }, + { + "title": "UUp Dump ISO creator Custom", + "menu": "Utilities and Tools", + "script": "utilities/uup_dump_iso_creator.sh", + "keywords": "windows iso uup dump" + }, + { + "title": "System Utilities Installer", + "menu": "Utilities and Tools", + "script": "utilities/system_utils.sh", + "keywords": "utilities tools herramientas paquetes packages" + }, + { + "title": "Proxmox System Update", + "menu": "Utilities and Tools", + "script": "menus/utilities_menu.sh", + "keywords": "update upgrade actualizar apt", + "argument": "3" + }, + { + "title": "Upgrade PVE 8 to PVE 9", + "menu": "Utilities and Tools", + "script": "menus/utilities_menu.sh", + "keywords": "upgrade pve8 pve9 actualizar", + "argument": "4" + }, + { + "title": "Export VM to OVA or OVF", + "menu": "Utilities and Tools", + "script": "utilities/export_vm_ova_ovf.sh", + "keywords": "export exportar vm ova ovf" + }, + { + "title": "Import VM from OVA or OVF", + "menu": "Utilities and Tools", + "script": "utilities/import_vm_ova_ovf.sh", + "keywords": "import importar vm ova ovf" + }, + { + "title": "Useful System Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "1" + }, + { + "title": "VM and CT Management Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "2" + }, + { + "title": "Storage and Disks Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "3" + }, + { + "title": "Network Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "4" + }, + { + "title": "Updates and Packages Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "5" + }, + { + "title": "GPU Passthrough Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "6" + }, + { + "title": "ZFS Management Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "7" + }, + { + "title": "Backup and Restore Commands", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "8" + }, + { + "title": "System CLI Tools", + "menu": "Help and Info Commands", + "script": "help_info_menu.sh", + "keywords": "help ayuda commands comandos cli", + "argument": "9" + }, + { + "title": "Change Language", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "language idioma", + "argument": "change_language" + }, + { + "title": "Change Release Channel", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "beta stable channel canal", + "argument": "change_release_channel" + }, + { + "title": "Show Version Information", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "version", + "argument": "show_version_info" + }, + { + "title": "Uninstall ProxMenux", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "uninstall desinstalar", + "argument": "uninstall_proxmenu" + }, + { + "title": "Reset ProxMenux Monitor Password", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "monitor password contraseña", + "argument": "reset_monitor_password" + }, + { + "title": "Show Monitor Service Status", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "monitor service servicio", + "argument": "show_monitor_status" + }, + { + "title": "Activate ProxMenux Monitor", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "monitor", + "argument": "toggle_monitor" + }, + { + "title": "Deactivate ProxMenux Monitor", + "menu": "Settings", + "script": "menus/config_menu.sh", + "keywords": "monitor", + "argument": "toggle_monitor" + } +] diff --git a/scripts/menus/search_menu.py b/scripts/menus/search_menu.py new file mode 100644 index 00000000..f1806912 --- /dev/null +++ b/scripts/menus/search_menu.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# ========================================================== +# ProxMenux - Menu search +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : GPL-3.0 +# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE +# ========================================================== +# Description: +# Finds an option by name or by related words across the ProxMenux menu, +# the OCI catalog and the Helper-Scripts, and opens it where it lives. +# ========================================================== +"""Search across the ProxMenux menu, the OCI catalog and the Helper-Scripts.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time +import unicodedata +import urllib.request + +BASE = Path('/usr/local/share/proxmenux') +SCRIPTS = BASE / 'scripts' +INDEX = Path(__file__).resolve().with_name('search_index.json') +OCI_INDEX = BASE / 'oci/engine/catalog/index.json' +HELPERS_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json' +HELPERS_CACHE = BASE / 'helpers_cache.json' +HELPERS_MAX_AGE = 24 * 3600 +# Results come in this order: the menu itself, then the two catalogs. +SOURCES = ('ProxMenux', 'OCI Manager', 'Helper-Scripts') +# Words an OCI application is also found by, when its name does not say them. +OCI_KEYWORDS = {'sharecovex': 'samba smb cifs nfs share compartir archivos time machine'} + +_translations = None + + +def translate(text): + """The text in the language of the menu; the text itself when it has none.""" + global _translations + if _translations is None: + _translations = {} + try: + language = json.loads((BASE / 'config.json').read_text()).get('language', 'en') + if isinstance(language, str) and re.fullmatch(r'[a-z]{2}', language) and language != 'en': + loaded = json.loads((BASE / 'lang' / f'{language}.json').read_text(encoding='utf-8')) + if isinstance(loaded, dict): + _translations = loaded + except (OSError, ValueError): + pass + value = _translations.get(text) + return value if isinstance(value, str) and value else text + + +def tidy(text): + """A menu label on one line: the menus pad theirs to line them up.""" + return re.sub(r'\s+', ' ', text).strip() + + +def fold(value): + return ''.join(c for c in unicodedata.normalize('NFKD', value.casefold()) if not unicodedata.combining(c)) + + +def returns_to_a_menu(script): + """Whether a script ends by opening a menu itself, as the sub-menus do.""" + try: + return bool(re.search(r'exec bash "\$(LOCAL_SCRIPTS|MENU_REPO)/', script.read_text(errors='ignore'))) + except OSError: + return False + + +def own_entries(): + try: + index = json.loads(INDEX.read_text(encoding='utf-8')) + except (OSError, ValueError): + return [] + entries = [] + for item in index: + script = SCRIPTS / item['script'] + if not script.is_file(): + continue + title = tidy(translate(item['title'])) + # An option that lives inside a menu is given to that menu, which + # opens it first. + option = [item['argument']] if item.get('argument') else [] + entries.append(dict(title=title, source='ProxMenux', kind=tidy(translate(item['menu'])), + terms=f"{title} {tidy(item['title'])} {item.get('keywords', '')}", related='', + action=['bash', str(script), *option], handoff=returns_to_a_menu(script))) + return entries + + +def utility_entries(): + source = SCRIPTS / 'global/utils-install-functions.sh' + installer = SCRIPTS / 'utilities/system_utils.sh' + try: + block = re.search(r'^PROXMENUX_UTILS=\(\s*\n(.*?)^\)', source.read_text(), re.M | re.S) + except OSError: + return [] + if not block or not installer.is_file(): + return [] + kind = tidy(translate('System Utilities Installer')) + return [dict(title=package, source='ProxMenux', kind=kind, terms=f'{package} {command}', + related=f'{description} {translate(description)}', + action=['bash', str(installer), package], handoff=False) + for package, command, description in + re.findall(r'^\s*"([^":]+):([^":]+):([^"\n]+)"', block.group(1), re.M)] + + +def post_install_entries(): + script = SCRIPTS / 'post_install/customizable_post_install.sh' + try: + block = re.search(r'^\s*local options=\(\s*\n(.*?)^\s*\)', script.read_text(), re.M | re.S) + except OSError: + return [] + if not block: + return [] + kind = tidy(translate('Customizable post-installation script')) + return [dict(title=tidy(translate(title)), source='ProxMenux', kind=kind, + terms=f'{translate(title)} {title} {token}', related=f'{category} post postinstall', + action=['bash', str(script), token], handoff=False) + for category, title, token in + re.findall(r'^\s*"([^"|]+)\|([^"|]+)\|([A-Za-z0-9_]+)"', block.group(1), re.M)] + + +def oci_entries(): + launcher = SCRIPTS / 'oci/oci_manager_apps.sh' + try: + applications = json.loads(OCI_INDEX.read_text(encoding='utf-8')).get('applications', []) + except (OSError, ValueError): + return [] + if not launcher.is_file(): + return [] + kind = translate('OCI application') + return [dict(title=item.get('title') or item['id'], source='OCI Manager', kind=kind, + terms=f"{item.get('title') or item['id']} {item['id']} {OCI_KEYWORDS.get(item['id'], '')}", + related=str(item.get('category_label') or ''), + action=['bash', str(launcher), 'open', item['id']], handoff=False) + for item in applications + if not item.get('hidden') and item.get('automatic_install_candidate')] + + +def helpers_catalog(refresh=True): + """The Helper-Scripts catalog. It is kept on the host and downloaded again + once a day; without a connection the one already here is used.""" + try: + fresh = time.time() - HELPERS_CACHE.stat().st_mtime < HELPERS_MAX_AGE + except OSError: + fresh = False + if refresh and not fresh: + try: + with urllib.request.urlopen(HELPERS_URL, timeout=15) as response: + data = response.read() + if isinstance(json.loads(data), list): + temporary = HELPERS_CACHE.with_name(HELPERS_CACHE.name + '.new') + temporary.write_bytes(data) + os.replace(temporary, HELPERS_CACHE) + except (OSError, ValueError): + pass + try: + loaded = json.loads(HELPERS_CACHE.read_text(encoding='utf-8')) + except (OSError, ValueError): + return [] + return loaded if isinstance(loaded, list) else [] + + +def helper_entries(refresh=True): + launcher = SCRIPTS / 'menus/menu_Helper_Scripts.sh' + if not launcher.is_file(): + return [] + entries, seen = [], set() + for item in helpers_catalog(refresh): + slug = item.get('slug') + if not slug or slug in seen or not item.get('name'): + continue + seen.add(slug) + entries.append(dict(title=item['name'], source='Helper-Scripts', kind=str(item.get('type', '')).upper(), + terms=f"{item['name']} {slug}", + related=' '.join(item.get('category_names', [])), + action=['bash', str(launcher), slug], handoff=False)) + return entries + + +def catalog(refresh=True): + return own_entries() + utility_entries() + post_install_entries() + oci_entries() + helper_entries(refresh) + + +def search(items, query): + """What matches every word of the query: by its name first, then by its + category. The long description of an application is not searched, as it + names other applications and would bring them all.""" + query = fold(query.strip()) + wanted = re.findall(r'[a-z0-9]+', query) + if not wanted: + return [] + + def has_all(text): + words = re.findall(r'[a-z0-9]+', text) + return all(any(word.startswith(term) for word in words) for term in wanted) + + ranked = [] + for item in items: + name = fold(item['title']) + terms = fold(item['terms']) + if has_all(terms): + depth = 0 + elif has_all(f"{terms} {fold(item['related'])}"): + depth = 1 + else: + continue + score = 0 if name == query else 1 if name.startswith(query) else 2 if query in name else 3 + source = SOURCES.index(item['source']) if item['source'] in SOURCES else len(SOURCES) + ranked.append((depth, source, score, name, item)) + return [row[4] for row in sorted(ranked, key=lambda row: row[:4])] + + +def result_rows(matches, width): + """One line per result in three columns. Only what is shown is cut.""" + labels = (translate('Name'), translate('Source'), translate('Type')) + source_width = max(len(labels[1]), max(len(item['source']) for item in matches)) + kind_width = min(29, max(len(labels[2]), max(len(item['kind']) for item in matches))) + name_width = max(12, width - source_width - kind_width - 6) + + def cell(value, size): + value = re.sub(r'[\x00-\x1f\x7f]', ' ', value) + return (value if len(value) <= size else value[:size - 1] + '…').ljust(size) + + def line(name, source, kind): + return f'{cell(name, name_width)} {cell(source, source_width)} {cell(kind, kind_width)}' + + return line(*labels), [(str(number), line(item['title'], item['source'], item['kind'])) + for number, item in enumerate(matches)] + + +def dialog(kind, title, text, rows=(), initial='', width=100, height=23): + terminal = shutil.get_terminal_size((100, 30)) + width = max(40, min(width, terminal.columns)) + height = max(8, min(height, terminal.lines - 2)) + # The heading of the results is lined up with spaces, which are kept. + args = ['dialog', '--clear', '--no-collapse', '--backtitle', 'ProxMenux', '--title', title] + if kind == 'menu': + args += ['--menu', text, str(height), str(width), str(max(3, min(len(rows), height - 6)))] + for tag, label in rows: + args += [tag, label] + elif kind == 'inputbox': + args += ['--inputbox', text, '10', str(min(width, 90)), initial] + elif kind == 'infobox': + args += ['--infobox', text, '5', str(min(width, 60))] + else: + args += ['--msgbox', text, '8', str(min(width, 70))] + with open('/dev/tty', 'w') as tty: + result = subprocess.run(args + ['--stdout'], stdout=subprocess.PIPE, stderr=tty, text=True, check=False) + return result.stdout.strip() if result.returncode == 0 else None + + +def show_results(matches, query, handoff): + """The results, until one that opens a menu is chosen or the list is left.""" + while True: + columns = min(100, shutil.get_terminal_size((100, 24)).columns) + heading, rows = result_rows(matches, max(60, columns - 16)) + tag_width = max(len(tag) for tag, _ in rows) + row_width = max(len(label) for _, label in rows) + # The list is centred in its window; the heading starts where its rows do. + heading = ' ' * (max(0, (columns - 8 - row_width - tag_width - 2) // 2) + tag_width + 4) + heading + selected = dialog('menu', f"{translate('Results:')} {query} ({len(matches)})", heading, rows) + if selected is None: + return False + item = matches[int(selected)] + if item['handoff'] and handoff: + # A sub-menu goes back to the main menu on its own: the main menu opens it. + handoff.write_text('\n'.join(item['action'][1:]) + '\n') + return True + subprocess.run(item['action'], check=False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--search', metavar='QUERY', help='print the matches as JSON and exit') + parser.add_argument('--handoff', type=Path, help='file that receives the sub-menu the main menu has to open') + args = parser.parse_args() + if args.search is not None: + print(json.dumps(search(catalog(), args.search), ensure_ascii=False, indent=2)) + return + try: + outdated = time.time() - HELPERS_CACHE.stat().st_mtime >= HELPERS_MAX_AGE + except OSError: + outdated = True + if outdated: + dialog('infobox', translate('Search'), '\n' + translate('Loading the Helper-Scripts catalog...')) + items = catalog() + query = '' + while True: + query = dialog('inputbox', translate('Search'), + translate('Name or related words: zima, plex, post…'), initial=query) + if query is None: + return + matches = search(items, query) + if not matches: + dialog('msgbox', translate('No results'), f"\n{translate('No matches for:')} {query}") + continue + if show_results(matches, query, args.handoff): + return + + +if __name__ == '__main__': + main() diff --git a/scripts/menus/utilities_menu.sh b/scripts/menus/utilities_menu.sh index 4b5b897c..c31c1c98 100644 --- a/scripts/menus/utilities_menu.sh +++ b/scripts/menus/utilities_menu.sh @@ -44,7 +44,14 @@ load_language initialize_cache # ========================================================== + # An option given as argument, as the menu search does, is opened first; + # the menu follows. + PRESELECTED_OPTION="${1:-}" while true; do + if [[ "$PRESELECTED_OPTION" =~ ^[1-6]$ ]]; then + OPTION="$PRESELECTED_OPTION" + PRESELECTED_OPTION="" + else OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Utilities Menu")" \ --menu "\n$(translate "Select an option:")" 20 70 11 \ "1" "$(translate "UUp Dump ISO creator Custom")" \ @@ -55,6 +62,7 @@ initialize_cache "6" "$(translate "Import VM from OVA or OVF")" \ "7" "$(translate "Return to Main Menu")" \ 2>&1 >/dev/tty) + fi case $OPTION in 1) diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 73671115..ea6f1bea 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -3812,6 +3812,7 @@ main_menu() { local checklist_items=() local i=1 + local preselected_item="" local desc_index=0 local previous_category="" @@ -3830,7 +3831,12 @@ main_menu() { local line line="$(format_custom_post_line "$desc_translated" "$category_translated")" - checklist_items+=("$i" "$line" "off") + local state="off" + if [[ -n "${PRESELECTED_OPTION:-}" && "$function_name" == "$PRESELECTED_OPTION" ]]; then + state="on" + preselected_item="$i" + fi + checklist_items+=("$i" "$line" "$state") i=$((i + 1)) previous_category="$category" done @@ -3839,6 +3845,7 @@ main_menu() { selected_indices=$(dialog --clear \ --backtitle "ProxMenux" \ --title "$(translate "Post-Installation Options")" \ + --default-item "${preselected_item:-1}" \ --checklist "$HEADER" 22 88 15 \ "${checklist_items[@]}" \ 2>&1 1>&3) @@ -3967,6 +3974,8 @@ done # update wrapper that re-runs a single function), don't trigger the # extremeshok warning or the main menu. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # An option given as argument, as the menu search does, comes selected. + PRESELECTED_OPTION="${1:-}" check_extremeshok_warning main_menu fi diff --git a/scripts/utilities/system_utils.sh b/scripts/utilities/system_utils.sh index cae5ca33..afa9ac3c 100644 --- a/scripts/utilities/system_utils.sh +++ b/scripts/utilities/system_utils.sh @@ -47,6 +47,9 @@ if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then fi # ========================================================== +# A package given as argument, as the menu search does, opens the custom +# selection with it marked. +PRESELECTED_UTILITY="${1:-}" install_system_utils() { show_main_utilities_menu() { @@ -71,7 +74,9 @@ install_system_utils() { local utilities=() for util_entry in "${PROXMENUX_UTILS[@]}"; do IFS=':' read -r pkg cmd desc <<< "$util_entry" - utilities+=("$pkg" "$(translate "$desc")" "OFF") + local state="OFF" + [[ -n "$PRESELECTED_UTILITY" && "$pkg" == "$PRESELECTED_UTILITY" ]] && state="ON" + utilities+=("$pkg" "$(translate "$desc")" "$state") done local selected @@ -205,10 +210,15 @@ install_system_utils() { # Main menu loop while true; do - choice=$(show_main_utilities_menu) + if [[ -n "$PRESELECTED_UTILITY" ]]; then + choice=1 + else + choice=$(show_main_utilities_menu) + fi case $choice in 1) selected=$(show_custom_selection) + PRESELECTED_UTILITY="" install_selected_utilities "$selected" ;; 2)