Merge pull request 'clan-vm-manager: connect log view to build state of machines' (#989) from hsjobeki-main into main

This commit is contained in:
clan-bot
2024-03-17 13:14:49 +00:00
6 changed files with 225 additions and 35 deletions

View File

@@ -5,7 +5,7 @@ import tempfile
import threading import threading
import time import time
import weakref import weakref
from collections.abc import Generator from collections.abc import Callable, Generator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path 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("GObject", "2.0")
gi.require_version("Gtk", "4.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__) log = logging.getLogger(__name__)
@@ -29,19 +29,23 @@ log = logging.getLogger(__name__)
class VMObject(GObject.Object): class VMObject(GObject.Object):
# Define a custom signal with the name "vm_stopped" and a string argument for the message # Define a custom signal with the name "vm_stopped" and a string argument for the message
__gsignals__: ClassVar = { __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__( def __init__(
self, self,
icon: Path, icon: Path,
data: HistoryEntry, data: HistoryEntry,
build_log_cb: Callable[[Gio.File], None],
) -> None: ) -> None:
super().__init__() super().__init__()
# Store the data from the history entry # Store the data from the history entry
self.data: HistoryEntry = data self.data: HistoryEntry = data
self.build_log_cb = build_log_cb
# Create a process object to store the VM process # Create a process object to store the VM process
self.vm_process: MPProcess = MPProcess( self.vm_process: MPProcess = MPProcess(
"vm_dummy", mp.Process(), Path("./dummy") "vm_dummy", mp.Process(), Path("./dummy")
@@ -89,6 +93,9 @@ class VMObject(GObject.Object):
self.data = data self.data = data
def _on_vm_status_changed(self, source: "VMObject") -> None: 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()) self.switch.set_state(self.is_running() and not self.is_building())
if self.switch.get_sensitive() is False and not self.is_building(): if self.switch.get_sensitive() is False and not self.is_building():
self.switch.set_sensitive(True) self.switch.set_sensitive(True)
@@ -154,6 +161,14 @@ class VMObject(GObject.Object):
machine=machine, machine=machine,
tmpdir=log_dir, 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) GLib.idle_add(self._vm_status_changed_task)
self.switch.set_sensitive(True) self.switch.set_sensitive(True)
# Start the logs watcher # Start the logs watcher
@@ -206,6 +221,18 @@ class VMObject(GObject.Object):
log.debug(f"VM {self.get_id()} has stopped") log.debug(f"VM {self.get_id()} has stopped")
GLib.idle_add(self._vm_status_changed_task) 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: def start(self) -> None:
if self.is_running(): if self.is_running():
log.warn("VM is already running. Ignoring start request") log.warn("VM is already running. Ignoring start request")

View File

@@ -1,4 +1,5 @@
import logging import logging
from collections.abc import Callable
from typing import Any from typing import Any
import gi import gi
@@ -50,20 +51,86 @@ class ToastOverlay:
class ErrorToast: class ErrorToast:
toast: Adw.Toast toast: Adw.Toast
def __init__(self, message: str) -> None: def __init__(
self, message: str, persistent: bool = False, details: str = ""
) -> None:
super().__init__() super().__init__()
self.toast = Adw.Toast.new(f"Error: {message}") self.toast = Adw.Toast.new(
self.toast.set_priority(Adw.ToastPriority.HIGH) f"""<span foreground='red'>❌ Error </span> {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 views = ViewStack.use().view
# we cannot check this type, python is not smart enough # we cannot check this type, python is not smart enough
logs_view: Logs = views.get_child_by_name("logs") # type: ignore logs_view: Logs = views.get_child_by_name("logs") # type: ignore
logs_view.set_message(message) logs_view.set_message(details)
self.toast.connect( self.toast.connect(
"button-clicked", "button-clicked",
lambda _: views.set_visible_child_name("logs"), 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"<span foreground='orange'>⚠ Warning </span> {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"<span foreground='green'>✅</span> {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 <span weight="regular">{message}</span>"""
)
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(),
)

View File

@@ -10,10 +10,12 @@ from clan_cli.history.add import HistoryEntry
from clan_vm_manager import assets from clan_vm_manager import assets
from clan_vm_manager.components.gkvstore import GKVStore from clan_vm_manager.components.gkvstore import GKVStore
from clan_vm_manager.components.vmobj import VMObject 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("GObject", "2.0")
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import GLib from gi.repository import Gio, GLib
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -27,6 +29,10 @@ class ClanStore:
_instance: "None | ClanStore" = None _instance: "None | ClanStore" = None
_clan_store: GKVStore[str, VMStore] _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 # Make sure the VMS class is used as a singleton
def __init__(self) -> None: def __init__(self) -> None:
raise RuntimeError("Call use() instead") raise RuntimeError("Call use() instead")
@@ -41,6 +47,13 @@ class ClanStore:
return cls._instance 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( def register_on_deep_change(
self, callback: Callable[[GKVStore, int, int, int], None] self, callback: Callable[[GKVStore, int, int, int], None]
) -> None: ) -> None:
@@ -77,12 +90,41 @@ class ClanStore:
else: else:
icon = Path(entry.flake.icon) icon = Path(entry.flake.icon)
vm = VMObject( def log_details(gfile: Gio.File) -> None:
icon=icon, self.log_details(vm, gfile)
data=entry,
) vm = VMObject(icon=icon, data=entry, build_log_cb=log_details)
self.push(vm) 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: def push(self, vm: VMObject) -> None:
url = str(vm.data.flake.flake_url) url = str(vm.data.flake.flake_url)

View File

@@ -8,9 +8,15 @@ from clan_cli.clan_uri import ClanURI
from clan_vm_manager.components.interfaces import ClanConfig from clan_vm_manager.components.interfaces import ClanConfig
from clan_vm_manager.components.vmobj import VMObject 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_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.singletons.use_vms import ClanStore, VMStore
from clan_vm_manager.views.logs import Logs
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
@@ -168,14 +174,42 @@ class ClanList(Gtk.Box):
## Drop down menu ## Drop down menu
open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s")) open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s"))
open_action.connect("activate", self.on_edit) 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() app = Gio.Application.get_default()
assert app is not None assert app is not None
app.add_action(open_action) 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 = Gio.Menu()
menu_model.append("Edit", f"app.edit::{vm.get_id()}") 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 = Gtk.MenuButton()
pref_button.set_icon_name("open-menu-symbolic") pref_button.set_icon_name("open-menu-symbolic")
pref_button.set_menu_model(menu_model) pref_button.set_menu_model(menu_model)
button_box.append(pref_button) button_box.append(pref_button)
## VM switch button ## VM switch button
@@ -190,9 +224,31 @@ class ClanList(Gtk.Box):
def on_edit(self, source: Any, parameter: Any) -> None: def on_edit(self, source: Any, parameter: Any) -> None:
target = parameter.get_string() target = parameter.get_string()
print("Editing settings for machine", target) 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"""📄<span weight="normal"> {name}</span>""")
logs.set_message("Loading ...")
views.set_visible_child_name("logs")
def render_join_row( def render_join_row(
self, boxed_list: Gtk.ListBox, join_val: JoinValue self, boxed_list: Gtk.ListBox, join_val: JoinValue
) -> Gtk.Widget: ) -> Gtk.Widget:
@@ -214,7 +270,9 @@ class ClanList(Gtk.Box):
assert sub is not None assert sub is not None
ToastOverlay.use().add_toast_unique( ToastOverlay.use().add_toast_unique(
ErrorToast("Already exists. Joining again will update it").toast, WarningToast(
f"""<span weight="regular">{join_val.url.machine.name!s}</span> Already exists. Joining again will update it"""
).toast,
"warning.duplicate.join", "warning.duplicate.join",
) )

View File

@@ -22,31 +22,27 @@ class Logs(Gtk.Box):
app = Gio.Application.get_default() app = Gio.Application.get_default()
assert app is not None 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_revealed(True)
self.banner.set_button_label("Close")
close_button = Gtk.Button() self.banner.connect(
button_content = Adw.ButtonContent.new() "button-clicked",
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",
lambda _: ViewStack.use().view.set_visible_child_name("list"), lambda _: ViewStack.use().view.set_visible_child_name("list"),
) )
self.close_button = close_button
self.text_view = Gtk.TextView() self.text_view = Gtk.TextView()
self.text_view.set_editable(False) self.text_view.set_editable(False)
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
self.text_view.add_css_class("log-view") self.text_view.add_css_class("log-view")
self.append(self.close_button)
self.append(self.banner) self.append(self.banner)
self.append(self.text_view) self.append(self.text_view)
def set_title(self, title: str) -> None:
self.banner.set_title(title)
def set_message(self, message: str) -> None: def set_message(self, message: str) -> None:
""" """
Set the log message. This will delete any previous message Set the log message. This will delete any previous message

View File

@@ -46,22 +46,22 @@ class MainWindow(Adw.ApplicationWindow):
# Initialize all views # Initialize all views
stack_view = ViewStack.use().view stack_view = ViewStack.use().view
clamp = Adw.Clamp()
clamp.set_child(stack_view)
clamp.set_maximum_size(1000)
scroll = Gtk.ScrolledWindow() scroll = Gtk.ScrolledWindow()
scroll.set_propagate_natural_height(True) scroll.set_propagate_natural_height(True)
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 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(Details(), "details")
stack_view.add_named(Logs(), "logs") stack_view.add_named(Logs(), "logs")
stack_view.set_visible_child_name(config.initial_view) stack_view.set_visible_child_name(config.initial_view)
clamp = Adw.Clamp() view.set_content(scroll)
clamp.set_child(stack_view)
clamp.set_maximum_size(1000)
view.set_content(clamp)
self.connect("destroy", self.on_destroy) self.connect("destroy", self.on_destroy)