diff --git a/pkgs/clan-vm-manager/clan_vm_manager/components/vmobj.py b/pkgs/clan-vm-manager/clan_vm_manager/components/vmobj.py index 5caf7b02c..aa613e1e2 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/components/vmobj.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/components/vmobj.py @@ -5,7 +5,7 @@ import tempfile import threading import time import weakref -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import datetime from pathlib import Path @@ -21,7 +21,7 @@ from clan_vm_manager.components.executor import MPProcess, spawn gi.require_version("GObject", "2.0") gi.require_version("Gtk", "4.0") -from gi.repository import GLib, GObject, Gtk +from gi.repository import Gio, GLib, GObject, Gtk log = logging.getLogger(__name__) @@ -29,19 +29,23 @@ log = logging.getLogger(__name__) class VMObject(GObject.Object): # Define a custom signal with the name "vm_stopped" and a string argument for the message __gsignals__: ClassVar = { - "vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, []) + "vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, []), + "vm_build_notify": (GObject.SignalFlags.RUN_FIRST, None, [bool, bool]), } def __init__( self, icon: Path, data: HistoryEntry, + build_log_cb: Callable[[Gio.File], None], ) -> None: super().__init__() # Store the data from the history entry self.data: HistoryEntry = data + self.build_log_cb = build_log_cb + # Create a process object to store the VM process self.vm_process: MPProcess = MPProcess( "vm_dummy", mp.Process(), Path("./dummy") @@ -89,6 +93,9 @@ class VMObject(GObject.Object): self.data = data def _on_vm_status_changed(self, source: "VMObject") -> None: + # Signal may be emited multiple times + self.emit("vm_build_notify", self.is_building(), self.is_running()) + self.switch.set_state(self.is_running() and not self.is_building()) if self.switch.get_sensitive() is False and not self.is_building(): self.switch.set_sensitive(True) @@ -154,6 +161,14 @@ class VMObject(GObject.Object): machine=machine, tmpdir=log_dir, ) + + gfile = Gio.File.new_for_path(str(log_dir / "build.log")) + # Gio documentation: + # Obtains a file monitor for the given file. + # If no file notification mechanism exists, then regular polling of the file is used. + g_monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None) + g_monitor.connect("changed", self.on_logs_changed) + GLib.idle_add(self._vm_status_changed_task) self.switch.set_sensitive(True) # Start the logs watcher @@ -206,6 +221,18 @@ class VMObject(GObject.Object): log.debug(f"VM {self.get_id()} has stopped") GLib.idle_add(self._vm_status_changed_task) + def on_logs_changed( + self, + monitor: Gio.FileMonitor, + file: Gio.File, + other_file: Gio.File, + event_type: Gio.FileMonitorEvent, + ) -> None: + if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT: + # File was changed and the changes were written to disk + # wire up the callback for setting the logs + self.build_log_cb(file) + def start(self) -> None: if self.is_running(): log.warn("VM is already running. Ignoring start request") diff --git a/pkgs/clan-vm-manager/clan_vm_manager/singletons/toast.py b/pkgs/clan-vm-manager/clan_vm_manager/singletons/toast.py index 941fc5d93..13d5843cc 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/singletons/toast.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/singletons/toast.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable from typing import Any import gi @@ -50,20 +51,86 @@ class ToastOverlay: class ErrorToast: toast: Adw.Toast - def __init__(self, message: str) -> None: + def __init__( + self, message: str, persistent: bool = False, details: str = "" + ) -> None: super().__init__() - self.toast = Adw.Toast.new(f"Error: {message}") - self.toast.set_priority(Adw.ToastPriority.HIGH) + self.toast = Adw.Toast.new( + f"""❌ Error {message}""" + ) + self.toast.set_use_markup(True) - self.toast.set_button_label("details") + self.toast.set_priority(Adw.ToastPriority.HIGH) + self.toast.set_button_label("Show more") + + if persistent: + self.toast.set_timeout(0) views = ViewStack.use().view # we cannot check this type, python is not smart enough logs_view: Logs = views.get_child_by_name("logs") # type: ignore - logs_view.set_message(message) + logs_view.set_message(details) self.toast.connect( "button-clicked", lambda _: views.set_visible_child_name("logs"), ) + + +class WarningToast: + toast: Adw.Toast + + def __init__(self, message: str, persistent: bool = False) -> None: + super().__init__() + self.toast = Adw.Toast.new( + f"⚠ Warning {message}" + ) + self.toast.set_use_markup(True) + + self.toast.set_priority(Adw.ToastPriority.NORMAL) + + if persistent: + self.toast.set_timeout(0) + + +class SuccessToast: + toast: Adw.Toast + + def __init__(self, message: str, persistent: bool = False) -> None: + super().__init__() + self.toast = Adw.Toast.new(f" {message}") + self.toast.set_use_markup(True) + + self.toast.set_priority(Adw.ToastPriority.NORMAL) + + if persistent: + self.toast.set_timeout(0) + + +class LogToast: + toast: Adw.Toast + + def __init__( + self, + message: str, + on_button_click: Callable[[], None], + button_label: str = "More", + persistent: bool = False, + ) -> None: + super().__init__() + self.toast = Adw.Toast.new( + f"""Logs are avilable {message}""" + ) + self.toast.set_use_markup(True) + + self.toast.set_priority(Adw.ToastPriority.NORMAL) + + if persistent: + self.toast.set_timeout(0) + + self.toast.set_button_label(button_label) + self.toast.connect( + "button-clicked", + lambda _: on_button_click(), + ) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/singletons/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/singletons/use_vms.py index 8a7254825..f6464d8ff 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/singletons/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/singletons/use_vms.py @@ -10,10 +10,12 @@ from clan_cli.history.add import HistoryEntry from clan_vm_manager import assets from clan_vm_manager.components.gkvstore import GKVStore from clan_vm_manager.components.vmobj import VMObject +from clan_vm_manager.singletons.use_views import ViewStack +from clan_vm_manager.views.logs import Logs gi.require_version("GObject", "2.0") gi.require_version("Gtk", "4.0") -from gi.repository import GLib +from gi.repository import Gio, GLib log = logging.getLogger(__name__) @@ -27,6 +29,10 @@ class ClanStore: _instance: "None | ClanStore" = None _clan_store: GKVStore[str, VMStore] + # set the vm that is outputting logs + # build logs are automatically streamed to the logs-view + _logging_vm: VMObject | None = None + # Make sure the VMS class is used as a singleton def __init__(self) -> None: raise RuntimeError("Call use() instead") @@ -41,6 +47,13 @@ class ClanStore: return cls._instance + def set_logging_vm(self, ident: str) -> VMObject | None: + vm = self.get_vm(ClanURI(f"clan://{ident}")) + if vm is not None: + self._logging_vm = vm + + return self._logging_vm + def register_on_deep_change( self, callback: Callable[[GKVStore, int, int, int], None] ) -> None: @@ -77,12 +90,41 @@ class ClanStore: else: icon = Path(entry.flake.icon) - vm = VMObject( - icon=icon, - data=entry, - ) + def log_details(gfile: Gio.File) -> None: + self.log_details(vm, gfile) + + vm = VMObject(icon=icon, data=entry, build_log_cb=log_details) self.push(vm) + def log_details(self, vm: VMObject, gfile: Gio.File) -> None: + views = ViewStack.use().view + logs_view: Logs = views.get_child_by_name("logs") # type: ignore + + def file_read_callback( + source_object: Gio.File, result: Gio.AsyncResult, _user_data: Any + ) -> None: + try: + # Finish the asynchronous read operation + res = source_object.load_contents_finish(result) + _success, contents, _etag_out = res + + # Convert the byte array to a string and print it + logs_view.set_message(contents.decode("utf-8")) + except Exception as e: + print(f"Error reading file: {e}") + + # only one vm can output logs at a time + if vm == self._logging_vm: + gfile.load_contents_async(None, file_read_callback, None) + else: + log.warning( + "Cannot log details of VM that is not the current logging VM.", + vm, + self._logging_vm, + ) + + # we cannot check this type, python is not smart enough + def push(self, vm: VMObject) -> None: url = str(vm.data.flake.flake_url) 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 c9e30da0b..a1979dd3e 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -8,9 +8,15 @@ from clan_cli.clan_uri import ClanURI from clan_vm_manager.components.interfaces import ClanConfig from clan_vm_manager.components.vmobj import VMObject -from clan_vm_manager.singletons.toast import ErrorToast, ToastOverlay +from clan_vm_manager.singletons.toast import ( + LogToast, + ToastOverlay, + WarningToast, +) from clan_vm_manager.singletons.use_join import JoinList, JoinValue +from clan_vm_manager.singletons.use_views import ViewStack from clan_vm_manager.singletons.use_vms import ClanStore, VMStore +from clan_vm_manager.views.logs import Logs gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk @@ -168,14 +174,42 @@ class ClanList(Gtk.Box): ## Drop down menu open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s")) open_action.connect("activate", self.on_edit) + + build_logs_action = Gio.SimpleAction.new("logs", GLib.VariantType.new("s")) + build_logs_action.connect("activate", self.on_show_build_logs) + build_logs_action.set_enabled(False) + app = Gio.Application.get_default() assert app is not None + app.add_action(open_action) + app.add_action(build_logs_action) + + # set a callback function for conditionally enabling the build_logs action + def on_vm_build_notify( + vm: VMObject, is_building: bool, is_running: bool + ) -> None: + build_logs_action.set_enabled(is_building or is_running) + app.add_action(build_logs_action) + if is_building: + ToastOverlay.use().add_toast_unique( + LogToast( + """Build process running ...""", + on_button_click=lambda: self.show_vm_build_logs(vm.get_id()), + ).toast, + f"info.build.running.{vm}", + ) + + vm.connect("vm_build_notify", on_vm_build_notify) + menu_model = Gio.Menu() menu_model.append("Edit", f"app.edit::{vm.get_id()}") + menu_model.append("Show Logs", f"app.logs::{vm.get_id()}") + pref_button = Gtk.MenuButton() pref_button.set_icon_name("open-menu-symbolic") pref_button.set_menu_model(menu_model) + button_box.append(pref_button) ## VM switch button @@ -190,9 +224,31 @@ class ClanList(Gtk.Box): def on_edit(self, source: Any, parameter: Any) -> None: target = parameter.get_string() - print("Editing settings for machine", target) + def on_show_build_logs(self, _: Any, parameter: Any) -> None: + target = parameter.get_string() + self.show_vm_build_logs(target) + + def show_vm_build_logs(self, target: str) -> None: + vm = ClanStore.use().set_logging_vm(target) + if vm is None: + raise ValueError(f"VM {target} not found") + + views = ViewStack.use().view + # Reset the logs view + logs: Logs = views.get_child_by_name("logs") # type: ignore + + if logs is None: + raise ValueError("Logs view not found") + + name = vm.machine.name if vm.machine else "Unknown" + + logs.set_title(f"""📄 {name}""") + logs.set_message("Loading ...") + + views.set_visible_child_name("logs") + def render_join_row( self, boxed_list: Gtk.ListBox, join_val: JoinValue ) -> Gtk.Widget: @@ -214,7 +270,9 @@ class ClanList(Gtk.Box): assert sub is not None ToastOverlay.use().add_toast_unique( - ErrorToast("Already exists. Joining again will update it").toast, + WarningToast( + f"""{join_val.url.machine.name!s} Already exists. Joining again will update it""" + ).toast, "warning.duplicate.join", ) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/logs.py b/pkgs/clan-vm-manager/clan_vm_manager/views/logs.py index 2374ba7f8..f7fb804f5 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/logs.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/logs.py @@ -22,31 +22,27 @@ class Logs(Gtk.Box): app = Gio.Application.get_default() assert app is not None - self.banner = Adw.Banner.new("Error details") + self.banner = Adw.Banner.new("") + self.banner.set_use_markup(True) self.banner.set_revealed(True) + self.banner.set_button_label("Close") - close_button = Gtk.Button() - button_content = Adw.ButtonContent.new() - button_content.set_label("Back") - button_content.set_icon_name("go-previous-symbolic") - close_button.add_css_class("flat") - close_button.set_child(button_content) - close_button.connect( - "clicked", + self.banner.connect( + "button-clicked", lambda _: ViewStack.use().view.set_visible_child_name("list"), ) - self.close_button = close_button - self.text_view = Gtk.TextView() self.text_view.set_editable(False) self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) self.text_view.add_css_class("log-view") - self.append(self.close_button) self.append(self.banner) self.append(self.text_view) + def set_title(self, title: str) -> None: + self.banner.set_title(title) + def set_message(self, message: str) -> None: """ Set the log message. This will delete any previous message diff --git a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py index 77bd4e870..887027325 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py @@ -46,22 +46,22 @@ class MainWindow(Adw.ApplicationWindow): # Initialize all views stack_view = ViewStack.use().view + clamp = Adw.Clamp() + clamp.set_child(stack_view) + clamp.set_maximum_size(1000) + scroll = Gtk.ScrolledWindow() scroll.set_propagate_natural_height(True) scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scroll.set_child(ClanList(config)) + scroll.set_child(clamp) - stack_view.add_named(scroll, "list") + stack_view.add_named(ClanList(config), "list") stack_view.add_named(Details(), "details") stack_view.add_named(Logs(), "logs") stack_view.set_visible_child_name(config.initial_view) - clamp = Adw.Clamp() - clamp.set_child(stack_view) - clamp.set_maximum_size(1000) - - view.set_content(clamp) + view.set_content(scroll) self.connect("destroy", self.on_destroy)