diff --git a/pkgs/clan-cli/clan_cli/machines/machines.py b/pkgs/clan-cli/clan_cli/machines/machines.py index 8bbdd3cef..fdb91b510 100644 --- a/pkgs/clan-cli/clan_cli/machines/machines.py +++ b/pkgs/clan-cli/clan_cli/machines/machines.py @@ -1,9 +1,9 @@ import json import logging -from os import path +from collections.abc import Generator +from contextlib import contextmanager from pathlib import Path from tempfile import NamedTemporaryFile -from time import sleep from clan_cli.dirs import vm_state_dir from qemu.qmp import QEMUMonitorProtocol @@ -16,6 +16,27 @@ from ..ssh import Host, parse_deployment_address log = logging.getLogger(__name__) +class VMAttr: + def __init__(self, state_dir: Path) -> None: + self._qmp_socket: Path = state_dir / "qmp.sock" + self._qga_socket: Path = state_dir / "qga.sock" + self._qmp: QEMUMonitorProtocol | None = None + + @contextmanager + def qmp(self) -> Generator[QEMUMonitorProtocol, None, None]: + if self._qmp is None: + log.debug(f"qmp_socket: {self._qmp_socket}") + rpath = self._qmp_socket.resolve() + if not rpath.exists(): + raise ClanError(f"qmp socket {rpath} does not exist") + self._qmp = QEMUMonitorProtocol(str(rpath)) + self._qmp.connect() + try: + yield self._qmp + finally: + self._qmp.close() + + class Machine: def __init__( self, @@ -36,14 +57,10 @@ class Machine: self.build_cache: dict[str, Path] = {} self._deployment_info: None | dict[str, str] = deployment_info + state_dir = vm_state_dir(flake_url=str(self.flake), vm_name=self.name) - self.qmp_socket: Path = state_dir / "qmp.sock" - self.qga_socket: Path = state_dir / "qga.sock" - - log.debug(f"qmp_socket: {self.qmp_socket}") - self._qmp = QEMUMonitorProtocol(path.realpath(self.qmp_socket)) - self._qmp_connected = False + self.vm: VMAttr = VMAttr(state_dir) def __str__(self) -> str: return f"Machine(name={self.name}, flake={self.flake})" @@ -60,28 +77,6 @@ class Machine: ) return self._deployment_info - def qmp_connect(self) -> None: - if not self._qmp_connected: - tries = 100 - for num in range(tries): - try: - # the socket file link might be outdated, therefore re-init the qmp object - self._qmp = QEMUMonitorProtocol(path.realpath(self.qmp_socket)) - self._qmp.connect() - self._qmp_connected = True - log.debug("QMP Connected") - return - except FileNotFoundError: - if num < 99: - sleep(0.1) - continue - else: - raise - - def qmp_command(self, command: str) -> dict: - self.qmp_connect() - return self._qmp.command(command) - @property def target_host_address(self) -> str: # deploymentAddress is deprecated. diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index 489a9cb86..487266fd4 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -154,7 +154,9 @@ def qemu_command( # TODO move this to the Machines class -def build_vm(machine: Machine, vm: VmConfig, nix_options: list[str]) -> dict[str, str]: +def build_vm( + machine: Machine, vm: VmConfig, nix_options: list[str] = [] +) -> dict[str, str]: config = nix_config() system = config["system"] diff --git a/pkgs/clan-cli/tests/test_vms_cli.py b/pkgs/clan-cli/tests/test_vms_cli.py index 4805ee63a..6e2720986 100644 --- a/pkgs/clan-cli/tests/test_vms_cli.py +++ b/pkgs/clan-cli/tests/test_vms_cli.py @@ -40,17 +40,25 @@ def run_vm_in_thread(machine_name: str) -> None: # wait for qmp socket to exist def wait_vm_up(state_dir: Path) -> None: socket_file = state_dir / "qga.sock" + timeout: float = 300 while True: + if timeout <= 0: + raise TimeoutError(f"qga socket {socket_file} not found") if socket_file.exists(): break sleep(0.1) + timeout -= 0.1 # wait for vm to be down by checking if qga socket is down def wait_vm_down(state_dir: Path) -> None: socket_file = state_dir / "qga.sock" + timeout: float = 300 while socket_file.exists(): + if timeout <= 0: + raise TimeoutError(f"qga socket {socket_file} still exists") sleep(0.1) + timeout -= 0.1 # wait for vm to be up then connect and return qmp instance diff --git a/pkgs/clan-vm-manager/README.md b/pkgs/clan-vm-manager/README.md index 0f283434c..bc8e04b00 100644 --- a/pkgs/clan-vm-manager/README.md +++ b/pkgs/clan-vm-manager/README.md @@ -1,88 +1,35 @@ -## Developing GTK3 Applications +## Developing GTK4 Applications -Here we will document on how to develop GTK3 application UI in python. First we want to setup -an example code base to look into. In this case gnome-music. - -## Setup gnome-music as code reference - -gnome-music does not use glade - -Clone gnome-music and check out the tag v40.0 -[gnome-music](https://github.com/GNOME/gnome-music/tree/40.0) +## Demos +Adw has a demo application showing all widgets. You can run it by executing: ```bash -git clone git@github.com:GNOME/gnome-music.git && cd gnome-music && git checkout 40.0 +adwaita-1-demo +``` +GTK4 has a demo application showing all widgets. You can run it by executing: +```bash +gtk4-widget-factory ``` -Checkout nixpkgs version `468cb5980b56d348979488a74a9b5de638400160` for the correct gnome-music devshell then execute: -```bash -nix develop /home/username/Projects/nixpkgs#gnome.gnome-music -``` - -Look into the file `gnome-music.in` which bootstraps the application. - -## Setup gnu-cash as reference - -Gnucash uses glade with complex UI -Setup gnucash - -```bash -git clone git@github.com:Gnucash/gnucash.git -git checkout ed4921271c863c7f6e0c800e206b25ac6e9ba4da - -cd nixpkgs -git checkout 015739d7bffa7da4e923978040a2f7cba6af3270 -nix develop /home/username/Projects/nixpkgs#gnucash -mkdir build && cd build -cmake .. -cd .. -make -``` - -- The use the GTK Builder instead of templates. - -## Look into virt-manager it uses python + spice-gtk - -Look into `virtManager/details/viewers.py` to see how spice-gtk is being used - -```bash -git clone https://github.com/virt-manager/virt-manager - -``` - -### Glade - -Make sure to check the 'composit' box in glade in the GtkApplicationWindow to be able to -import the glade file through GTK template ## Links +- [Adw PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Adw-1) +- [GTK4 PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Gtk-4.0) +- [Adw Widget Gallery](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/widget-gallery.html) +- [Python + GTK3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/textview.html) -- Another python glade project [syncthing-gtk](https://github.com/kozec/syncthing-gtk) -- Other python glade project [linuxcnc](https://github.com/podarok/linuxcnc/tree/master) - -- Install [Glade UI Toolbuilder](https://gitlab.gnome.org/GNOME/glade) - -- To understand GTK3 Components look into the [Python GTK3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/search.html?q=ApplicationWindow&check_keywords=yes&area=default) - -- https://web.archive.org/web/20100706201447/http://www.pygtk.org/pygtk2reference/ (GTK2 Reference, many methods still exist in gtk3) -- -- Also look into [PyGObject](https://pygobject.readthedocs.io/en/latest/guide/gtk_template.html) to know more about threading and async etc. -- [GI Python API](https://lazka.github.io/pgi-docs/#Gtk-3.0) -- https://developer.gnome.org/documentation/tutorials/application.html -- [GTK3 Python] https://github.com/sam-m888/python-gtk3-tutorial/tree/master -- https://gnome.pages.gitlab.gnome.org/libhandy/doc/1.8/index.html -- https://github.com/geigi/cozy -- https://github.com/lutris/lutris/blob/2e9bd115febe08694f5d42dabcf9da36a1065f1d/lutris/gui/widgets/cellrenderers.py#L92 ## Debugging Style and Layout +You can append `--debug` flag to enable debug logging printed into the console. + ```bash -# Enable the debugger +# Enable the GTK debugger gsettings set org.gtk.Settings.Debug enable-inspector-keybinding true # Start the application with the debugger attached -GTK_DEBUG=interactive ./bin/clan-vm-manager +GTK_DEBUG=interactive ./bin/clan-vm-manager --debug ``` diff --git a/pkgs/clan-vm-manager/clan_vm_manager/app.py b/pkgs/clan-vm-manager/clan_vm_manager/app.py index 8248d1f08..fe1ba4f37 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/app.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/app.py @@ -56,6 +56,7 @@ class MainApplication(Adw.Application): if "debug" in options: setup_logging("DEBUG", root_log_name=__name__.split(".")[0]) + setup_logging("DEBUG", root_log_name="clan_cli") else: setup_logging("INFO", root_log_name=__name__.split(".")[0]) log.debug("Debug logging enabled") diff --git a/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css b/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css index c08c53baa..1284730e2 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css +++ b/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css @@ -12,8 +12,8 @@ avatar { } .trust { - padding-top: 25px; - padding-bottom: 25px; + padding-top: 25px; + padding-bottom: 25px; } .join-list { @@ -22,11 +22,16 @@ avatar { } +.progress-bar { + margin-right: 25px; + min-width: 200px; +} + .group-list { background-color: inherit; } .group-list > .activatable:hover { - background-color: unset; + background-color: unset; } .group-list > row { diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py index 2e7f735c6..4f5f216ca 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py @@ -23,7 +23,7 @@ import multiprocessing as mp import threading from clan_cli.machines.machines import Machine -from gi.repository import Gio, GLib, GObject +from gi.repository import Gio, GLib, GObject, Gtk log = logging.getLogger(__name__) @@ -114,10 +114,15 @@ class VM(GObject.Object): self._stop_timer_init: datetime | None = None self._logs_id: int = 0 self._log_file: IO[str] | None = None + self.progress_bar: Gtk.ProgressBar = Gtk.ProgressBar() + self.progress_bar.hide() + self.progress_bar.set_hexpand(True) # Horizontally expand + self.prog_bar_id: int = 0 self.log_dir = tempfile.TemporaryDirectory( prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}" ) self._finalizer = weakref.finalize(self, self.stop) + self.connect("build_vm", self.build_vm) uri = ClanURI.from_str( url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr @@ -134,14 +139,43 @@ class VM(GObject.Object): flake=url, # type: ignore ) + def _pulse_progress_bar(self) -> bool: + self.progress_bar.pulse() + return GLib.SOURCE_CONTINUE + + def build_vm(self, vm: "VM", _vm: "VM", building: bool) -> None: + if building: + log.info("Building VM") + self.progress_bar.show() + self.prog_bar_id = GLib.timeout_add(100, self._pulse_progress_bar) + if self.prog_bar_id == 0: + raise ClanError("Couldn't spawn a progess bar task") + else: + self.progress_bar.hide() + if not GLib.Source.remove(self.prog_bar_id): + log.error("Failed to remove progress bar task") + log.info("VM built") + def __start(self) -> None: log.info(f"Starting VM {self.get_id()}") vm = vms.run.inspect_vm(self.machine) GLib.idle_add(self.emit, "build_vm", self, True) - vms.run.build_vm(self.machine, vm, []) + self.process = spawn( + on_except=None, + log_dir=Path(str(self.log_dir.name)), + func=vms.run.build_vm, + machine=self.machine, + vm=vm, + ) + self.process.proc.join() + GLib.idle_add(self.emit, "build_vm", self, False) + if self.process.proc.exitcode != 0: + log.error(f"Failed to build VM {self.get_id()}") + return + self.process = spawn( on_except=None, log_dir=Path(str(self.log_dir.name)), @@ -160,8 +194,6 @@ class VM(GObject.Object): if self._watcher_id == 0: raise ClanError("Failed to add watcher") - self.machine.qmp_connect() - def start(self) -> None: if self.is_running(): log.warn("VM is already running") @@ -220,7 +252,12 @@ class VM(GObject.Object): def __stop(self) -> None: log.info(f"Stopping VM {self.get_id()}") - self.machine.qmp_command("system_powerdown") + try: + with self.machine.vm.qmp() as qmp: + qmp.command("system_powerdown") + except ClanError as e: + log.debug(e) + self._stop_timer_init = datetime.now() self._stop_watcher_id = GLib.timeout_add(100, self.__shutdown_watchdog) if self._stop_watcher_id == 0: diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py index ed3279b84..b28f14ce4 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -54,6 +54,7 @@ class ClanList(Gtk.Box): groups = Clans.use() join = Join.use() + self.log_label: Gtk.Label = Gtk.Label() self.__init_machines = history.add.list_history() self.join_boxed_list = create_boxed_list( model=join.list_store, render_row=self.render_join_row @@ -126,25 +127,13 @@ class ClanList(Gtk.Box): self.group_list.add_css_class("no-shadow") def render_vm_row(self, boxed_list: Gtk.ListBox, vm: VM) -> Gtk.Widget: + # Remove no-shadow class if attached if boxed_list.has_css_class("no-shadow"): boxed_list.remove_css_class("no-shadow") flake = vm.data.flake row = Adw.ActionRow() - # Title - row.set_title(flake.flake_attr) - - row.set_title_lines(1) - row.set_title_selectable(True) - - # Subtitle - if flake.vm.machine_description: - row.set_subtitle(flake.vm.machine_description) - else: - row.set_subtitle(flake.clan_name) - row.set_subtitle_lines(1) - - # Avatar + # ====== Display Avatar ====== avatar = Adw.Avatar() machine_icon = flake.vm.machine_icon @@ -159,7 +148,26 @@ class ClanList(Gtk.Box): avatar.set_size(50) row.add_prefix(avatar) - # Switch + # ====== Display Name And Url ===== + row.set_title(flake.flake_attr) + + row.set_title_lines(1) + row.set_title_selectable(True) + + if flake.vm.machine_description: + row.set_subtitle(flake.vm.machine_description) + else: + row.set_subtitle(flake.clan_name) + row.set_subtitle_lines(1) + + # ==== Display build progress bar ==== + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + box.set_valign(Gtk.Align.CENTER) + box.append(vm.progress_bar) + box.set_homogeneous(False) + row.add_suffix(box) # This allows children to have different sizes + + # ==== Action buttons ==== switch = Gtk.Switch() switch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -169,10 +177,6 @@ class ClanList(Gtk.Box): box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) box.set_valign(Gtk.Align.CENTER) - # suffix_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - # suffix.set_halign(Gtk.Align.CENTER) - # suffix_box.append(switch) - open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s")) open_action.connect("activate", self.on_edit) @@ -190,7 +194,6 @@ class ClanList(Gtk.Box): switch.connect("notify::active", partial(self.on_row_toggle, vm)) vm.connect("vm_status_changed", partial(self.vm_status_changed, switch)) - vm.connect("build_vm", self.build_vm) # suffix.append(box) row.add_suffix(box) @@ -251,9 +254,6 @@ class ClanList(Gtk.Box): def show_error_dialog(self, error: str) -> None: p = Views.use().main_window - # app = Gio.Application.get_default() - # p = Gtk.Application.get_active_window(app) - dialog = Adw.MessageDialog(heading="Error") dialog.add_response("ok", "ok") dialog.set_body(error) @@ -294,12 +294,6 @@ class ClanList(Gtk.Box): row.set_state(True) vm.stop() - def build_vm(self, vm: VM, _vm: VM, building: bool) -> None: - if building: - log.info("Building VM") - else: - log.info("VM built") - def vm_status_changed(self, switch: Gtk.Switch, vm: VM, _vm: VM) -> None: switch.set_active(vm.is_running()) switch.set_state(vm.is_running()) diff --git a/pkgs/clan-vm-manager/shell.nix b/pkgs/clan-vm-manager/shell.nix index f20a2a682..48c032ab1 100644 --- a/pkgs/clan-vm-manager/shell.nix +++ b/pkgs/clan-vm-manager/shell.nix @@ -1,4 +1,4 @@ -{ lib, stdenv, clan-vm-manager, libadwaita, clan-cli, mkShell, ruff, desktop-file-utils, xdg-utils, mypy, python3Packages }: +{ lib, stdenv, clan-vm-manager, gtk4, libadwaita, clan-cli, mkShell, ruff, desktop-file-utils, xdg-utils, mypy, python3Packages }: mkShell { inherit (clan-vm-manager) propagatedBuildInputs buildInputs; @@ -11,6 +11,7 @@ mkShell { desktop-file-utils mypy python3Packages.ipdb + gtk4.dev libadwaita.devdoc # has the demo called 'adwaita-1-demo' ] ++ clan-vm-manager.nativeBuildInputs;