Merge pull request 'UI: Added signal handling for stopped and started vm.' (#750) from Qubasa-main into main
This commit is contained in:
@@ -38,11 +38,12 @@ FORMATTER = {
|
|||||||
|
|
||||||
|
|
||||||
class CustomFormatter(logging.Formatter):
|
class CustomFormatter(logging.Formatter):
|
||||||
|
def __init__(self, log_locations: bool) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.log_locations = log_locations
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
if record.levelno == logging.DEBUG:
|
return FORMATTER[record.levelno](record, self.log_locations).format(record)
|
||||||
return FORMATTER[record.levelno](record, True).format(record)
|
|
||||||
else:
|
|
||||||
return FORMATTER[record.levelno](record, False).format(record)
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadFormatter(logging.Formatter):
|
class ThreadFormatter(logging.Formatter):
|
||||||
@@ -75,7 +76,7 @@ def setup_logging(level: Any) -> None:
|
|||||||
|
|
||||||
# Create and add your custom handler
|
# Create and add your custom handler
|
||||||
default_handler.setLevel(level)
|
default_handler.setLevel(level)
|
||||||
default_handler.setFormatter(CustomFormatter())
|
default_handler.setFormatter(CustomFormatter(level == logging.DEBUG))
|
||||||
main_logger.addHandler(default_handler)
|
main_logger.addHandler(default_handler)
|
||||||
|
|
||||||
# Set logging level for other modules used by this module
|
# Set logging level for other modules used by this module
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import os
|
import shutil
|
||||||
from math import floor
|
from math import floor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
|
|
||||||
def get_term_filler(name: str) -> int:
|
def get_term_filler(name: str) -> int:
|
||||||
width, height = os.get_terminal_size()
|
width, height = shutil.get_terminal_size()
|
||||||
|
|
||||||
filler = floor((width - len(name)) / 2)
|
filler = floor((width - len(name)) / 2)
|
||||||
return filler - 1
|
return filler - 1
|
||||||
@@ -16,16 +15,25 @@ def text_heading(heading: str) -> str:
|
|||||||
return f"{'=' * filler} {heading} {'=' * filler}"
|
return f"{'=' * filler} {heading} {'=' * filler}"
|
||||||
|
|
||||||
|
|
||||||
class CmdOut(NamedTuple):
|
class CmdOut:
|
||||||
stdout: str
|
def __init__(
|
||||||
stderr: str
|
self,
|
||||||
cwd: Path
|
stdout: str,
|
||||||
command: str
|
stderr: str,
|
||||||
returncode: int
|
cwd: Path,
|
||||||
msg: str | None = None
|
command: str,
|
||||||
|
returncode: int,
|
||||||
|
msg: str | None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
self.cwd = cwd
|
||||||
|
self.command = command
|
||||||
|
self.returncode = returncode
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
def __str__(self) -> str:
|
self.error_str = f"""
|
||||||
return f"""
|
|
||||||
{text_heading(heading="Command")}
|
{text_heading(heading="Command")}
|
||||||
{self.command}
|
{self.command}
|
||||||
{text_heading(heading="Stderr")}
|
{text_heading(heading="Stderr")}
|
||||||
@@ -36,7 +44,10 @@ class CmdOut(NamedTuple):
|
|||||||
Message: {self.msg}
|
Message: {self.msg}
|
||||||
Working Directory: '{self.cwd}'
|
Working Directory: '{self.cwd}'
|
||||||
Return Code: {self.returncode}
|
Return Code: {self.returncode}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.error_str
|
||||||
|
|
||||||
|
|
||||||
class ClanError(Exception):
|
class ClanError(Exception):
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def _init_proc(
|
|||||||
func: Callable,
|
func: Callable,
|
||||||
out_file: Path,
|
out_file: Path,
|
||||||
proc_name: str,
|
proc_name: str,
|
||||||
on_except: Callable[[Exception, mp.process.BaseProcess], None],
|
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Create a new process group
|
# Create a new process group
|
||||||
@@ -89,6 +89,7 @@ def _init_proc(
|
|||||||
func(**kwargs)
|
func(**kwargs)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
if on_except is not None:
|
||||||
on_except(ex, mp.current_process())
|
on_except(ex, mp.current_process())
|
||||||
finally:
|
finally:
|
||||||
pid = os.getpid()
|
pid = os.getpid()
|
||||||
@@ -99,8 +100,8 @@ def _init_proc(
|
|||||||
|
|
||||||
def spawn(
|
def spawn(
|
||||||
*,
|
*,
|
||||||
log_path: Path,
|
log_dir: Path,
|
||||||
on_except: Callable[[Exception, mp.process.BaseProcess], None],
|
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
|
||||||
func: Callable,
|
func: Callable,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> MPProcess:
|
) -> MPProcess:
|
||||||
@@ -108,13 +109,13 @@ def spawn(
|
|||||||
if mp.get_start_method(allow_none=True) is None:
|
if mp.get_start_method(allow_none=True) is None:
|
||||||
mp.set_start_method(method="forkserver")
|
mp.set_start_method(method="forkserver")
|
||||||
|
|
||||||
if not log_path.is_dir():
|
if not log_dir.is_dir():
|
||||||
raise ClanError(f"Log path {log_path} is not a directory")
|
raise ClanError(f"Log path {log_dir} is not a directory")
|
||||||
log_path.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Set names
|
# Set names
|
||||||
proc_name = f"MPExec:{func.__name__}"
|
proc_name = f"MPExec:{func.__name__}"
|
||||||
out_file = log_path / "out.log"
|
out_file = log_dir / "out.log"
|
||||||
|
|
||||||
# Start the process
|
# Start the process
|
||||||
proc = mp.Process(
|
proc = mp.Process(
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import multiprocessing as mp
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from clan_cli.errors import ClanError
|
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
|
|
||||||
from clan_vm_manager.errors.show_error import show_error_dialog
|
|
||||||
from clan_vm_manager.models import VM, get_initial_vms
|
from clan_vm_manager.models import VM, get_initial_vms
|
||||||
|
|
||||||
|
|
||||||
# https://amolenaar.pages.gitlab.gnome.org/pygobject-docs/Adw-1/class-ToolbarView.html
|
|
||||||
# Will be executed in the context of the child process
|
|
||||||
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
|
|
||||||
show_error_dialog(ClanError(str(error)))
|
|
||||||
|
|
||||||
|
|
||||||
class VMS:
|
class VMS:
|
||||||
"""
|
"""
|
||||||
This is a singleton.
|
This is a singleton.
|
||||||
@@ -45,6 +37,14 @@ class VMS:
|
|||||||
cls.list_store.append(vm)
|
cls.list_store.append(vm)
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
def handle_vm_stopped(self, func: Callable[[VM, VM], None]) -> None:
|
||||||
|
for vm in self.list_store:
|
||||||
|
vm.connect("vm_stopped", func)
|
||||||
|
|
||||||
|
def handle_vm_started(self, func: Callable[[VM, VM], None]) -> None:
|
||||||
|
for vm in self.list_store:
|
||||||
|
vm.connect("vm_started", func)
|
||||||
|
|
||||||
def get_running_vms(self) -> list[VM]:
|
def get_running_vms(self) -> list[VM]:
|
||||||
return list(filter(lambda vm: vm.is_running(), self.list_store))
|
return list(filter(lambda vm: vm.is_running(), self.list_store))
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import multiprocessing as mp
|
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import weakref
|
import weakref
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
import gi
|
||||||
from clan_cli import vms
|
from clan_cli import vms
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
from clan_cli.history.add import HistoryEntry
|
from clan_cli.history.add import HistoryEntry
|
||||||
@@ -15,17 +17,24 @@ from clan_vm_manager import assets
|
|||||||
from .errors.show_error import show_error_dialog
|
from .errors.show_error import show_error_dialog
|
||||||
from .executor import MPProcess, spawn
|
from .executor import MPProcess, spawn
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
|
||||||
class VMStatus(StrEnum):
|
class VMStatus(StrEnum):
|
||||||
RUNNING = "Running"
|
RUNNING = "Running"
|
||||||
STOPPED = "Stopped"
|
STOPPED = "Stopped"
|
||||||
|
|
||||||
|
|
||||||
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
|
|
||||||
show_error_dialog(ClanError(str(error)))
|
|
||||||
|
|
||||||
|
|
||||||
class VM(GObject.Object):
|
class VM(GObject.Object):
|
||||||
|
# Define a custom signal with the name "vm_stopped" and a string argument for the message
|
||||||
|
__gsignals__: ClassVar = {
|
||||||
|
"vm_started": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]),
|
||||||
|
"vm_stopped": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
icon: Path,
|
icon: Path,
|
||||||
@@ -37,6 +46,9 @@ class VM(GObject.Object):
|
|||||||
self.data = data
|
self.data = data
|
||||||
self.process = process
|
self.process = process
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.log_dir = tempfile.TemporaryDirectory(
|
||||||
|
prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}"
|
||||||
|
)
|
||||||
self._finalizer = weakref.finalize(self, self.stop)
|
self._finalizer = weakref.finalize(self, self.stop)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -46,14 +58,23 @@ class VM(GObject.Object):
|
|||||||
vm = vms.run.inspect_vm(
|
vm = vms.run.inspect_vm(
|
||||||
flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr
|
flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr
|
||||||
)
|
)
|
||||||
log_path = Path(".")
|
|
||||||
|
|
||||||
self.process = spawn(
|
self.process = spawn(
|
||||||
on_except=on_except,
|
on_except=None,
|
||||||
log_path=log_path,
|
log_dir=Path(str(self.log_dir.name)),
|
||||||
func=vms.run.run_vm,
|
func=vms.run.run_vm,
|
||||||
vm=vm,
|
vm=vm,
|
||||||
)
|
)
|
||||||
|
self.emit("vm_started", self)
|
||||||
|
GLib.timeout_add(50, self.vm_stopped_task)
|
||||||
|
|
||||||
|
def start_async(self) -> None:
|
||||||
|
threading.Thread(target=self.start).start()
|
||||||
|
|
||||||
|
def vm_stopped_task(self) -> bool:
|
||||||
|
if not self.is_running():
|
||||||
|
self.emit("vm_stopped", self)
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
@@ -63,6 +84,9 @@ class VM(GObject.Object):
|
|||||||
def get_id(self) -> str:
|
def get_id(self) -> str:
|
||||||
return self.data.flake.flake_url + self.data.flake.flake_attr
|
return self.data.flake.flake_url + self.data.flake.flake_attr
|
||||||
|
|
||||||
|
def stop_async(self) -> None:
|
||||||
|
threading.Thread(target=self.stop).start()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if self.process is None:
|
if self.process is None:
|
||||||
print("VM is already stopped", file=sys.stderr)
|
print("VM is already stopped", file=sys.stderr)
|
||||||
@@ -71,6 +95,11 @@ class VM(GObject.Object):
|
|||||||
self.process.kill_group()
|
self.process.kill_group()
|
||||||
self.process = None
|
self.process = None
|
||||||
|
|
||||||
|
def read_log(self) -> str:
|
||||||
|
if self.process is None:
|
||||||
|
return ""
|
||||||
|
return self.process.out_file.read_text()
|
||||||
|
|
||||||
|
|
||||||
def get_initial_vms() -> list[VM]:
|
def get_initial_vms() -> list[VM]:
|
||||||
vm_list = []
|
vm_list = []
|
||||||
|
|||||||
@@ -64,17 +64,38 @@ class ClanList(Gtk.Box):
|
|||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
list_store = VMS.use().list_store
|
vms = VMS.use()
|
||||||
|
|
||||||
boxed_list.bind_model(list_store, create_widget_func=create_widget)
|
# TODO: Move this up to create_widget and connect every VM signal to its corresponding switch
|
||||||
|
vms.handle_vm_stopped(self.stopped_vm)
|
||||||
|
vms.handle_vm_started(self.started_vm)
|
||||||
|
|
||||||
|
boxed_list.bind_model(vms.list_store, create_widget_func=create_widget)
|
||||||
|
|
||||||
self.append(boxed_list)
|
self.append(boxed_list)
|
||||||
|
|
||||||
|
def started_vm(self, vm: VM, _vm: VM) -> None:
|
||||||
|
print("VM started", vm.data.flake.flake_attr)
|
||||||
|
|
||||||
|
def stopped_vm(self, vm: VM, _vm: VM) -> None:
|
||||||
|
print("VM stopped", vm.data.flake.flake_attr)
|
||||||
|
|
||||||
|
def show_error_dialog(self, error: str) -> None:
|
||||||
|
dialog = Gtk.MessageDialog(
|
||||||
|
parent=self.get_toplevel(),
|
||||||
|
modal=True,
|
||||||
|
message_type=Gtk.MessageType.ERROR,
|
||||||
|
buttons=Gtk.ButtonsType.OK,
|
||||||
|
text=error,
|
||||||
|
)
|
||||||
|
dialog.run()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None:
|
def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None:
|
||||||
print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active())
|
print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active())
|
||||||
|
|
||||||
if row.get_active():
|
if row.get_active():
|
||||||
vm.start()
|
vm.start_async()
|
||||||
|
|
||||||
if not row.get_active():
|
if not row.get_active():
|
||||||
vm.stop()
|
vm.stop_async()
|
||||||
|
|||||||
Reference in New Issue
Block a user