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)