virtui-manager 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vmanager/vmcard.py CHANGED
@@ -28,7 +28,7 @@ from .vm_actions import (
28
28
  clone_vm, rename_vm, create_vm_snapshot,
29
29
  restore_vm_snapshot, delete_vm_snapshot,
30
30
  create_external_overlay, commit_disk_changes,
31
- discard_overlay, delete_vm
31
+ discard_overlay, delete_vm, hibernate_vm
32
32
  )
33
33
 
34
34
  from .vm_queries import (
@@ -51,6 +51,7 @@ from .utils import (
51
51
  extract_server_name_from_uri,
52
52
  generate_tooltip_markdown,
53
53
  remote_viewer_cmd,
54
+ check_tmux,
54
55
  )
55
56
  from .constants import (
56
57
  ButtonLabels, TabTitles, StatusText,
@@ -66,12 +67,14 @@ class VMCardActions(Static):
66
67
  def compose(self):
67
68
  self.card.ui["start"] = Button(ButtonLabels.START, id="start", variant="success")
68
69
  self.card.ui["shutdown"] = Button(ButtonLabels.SHUTDOWN, id="shutdown", variant="primary")
70
+ self.card.ui["hibernate"] = Button(ButtonLabels.HIBERNATE_VM, id="hibernate", variant="primary")
69
71
  self.card.ui["stop"] = Button(ButtonLabels.FORCE_OFF, id="stop", variant="error")
70
72
  self.card.ui["pause"] = Button(ButtonLabels.PAUSE, id="pause", variant="primary")
71
73
  self.card.ui["resume"] = Button(ButtonLabels.RESUME, id="resume", variant="success")
72
74
  self.card.ui["configure-button"] = Button(ButtonLabels.CONFIGURE, id="configure-button", variant="primary")
73
75
  self.card.ui["web_console"] = Button(ButtonLabels.WEB_CONSOLE, id="web_console", variant="default")
74
76
  self.card.ui["connect"] = Button(ButtonLabels.CONNECT, id="connect", variant="default")
77
+ self.card.ui["tmux_console"] = Button(ButtonLabels.TEXT_CONSOLE, id="tmux_console", variant="default")
75
78
 
76
79
  self.card.ui["snapshot_take"] = Button(ButtonLabels.SNAPSHOT, id="snapshot_take", variant="primary")
77
80
  self.card.ui["snapshot_restore"] = Button(ButtonLabels.RESTORE_SNAPSHOT, id="snapshot_restore", variant="primary")
@@ -100,16 +103,18 @@ class VMCardActions(Static):
100
103
  yield self.card.ui["pause"]
101
104
  yield self.card.ui["resume"]
102
105
  with Vertical():
103
- yield self.card.ui["configure-button"]
104
106
  yield self.card.ui["web_console"]
105
107
  yield self.card.ui["connect"]
106
- with TabPane(self.card._get_snapshot_tab_title(num_snapshots=0), id="snapshot-tab"):
108
+ if os.environ.get("TMUX") and check_tmux():
109
+ yield self.card.ui["tmux_console"]
110
+ with TabPane(TabTitles.STATE_MANAGEMENT, id="snapshot-tab"):
107
111
  with Horizontal():
108
112
  with Vertical():
109
113
  yield self.card.ui["snapshot_take"]
110
114
  yield self.card.ui["snapshot_restore"]
111
115
  yield self.card.ui["snapshot_delete"]
112
116
  with Vertical():
117
+ yield self.card.ui["hibernate"]
113
118
  yield self.card.ui["create_overlay"]
114
119
  yield self.card.ui["commit_disk"]
115
120
  yield self.card.ui["discard_overlay"]
@@ -122,6 +127,7 @@ class VMCardActions(Static):
122
127
  yield self.card.ui["clone"]
123
128
  yield self.card.ui["migration"]
124
129
  with Vertical():
130
+ yield self.card.ui["configure-button"]
125
131
  yield self.card.ui["xml"]
126
132
  yield Static(classes="button-separator")
127
133
  yield self.card.ui["rename-button"]
@@ -184,21 +190,19 @@ class VMCard(Static):
184
190
  def _get_snapshot_tab_title(self, num_snapshots: int = -1) -> str:
185
191
  """Get snapshot tab title. Pass num_snapshots to avoid blocking libvirt call."""
186
192
  if num_snapshots == -1:
187
- # If no count provided, don't fetch it here to avoid blocking.
188
- # For now, return default if we can't get it cheaply.
189
- return TabTitles.SNAP_OVER_UPDATE # TabTitles.SNAPSHOT + "/" + TabTitles.OVERLAY
193
+ # If no count provided, don't fetch it here to avoid blocking.
194
+ # For now, return default if we can't get it cheaply.
195
+ return TabTitles.SNAP_OVER_UPDATE
190
196
 
191
197
  if self.vm:
192
198
  try:
193
- if num_snapshots == 0:
194
- return TabTitles.SNAPSHOT + "/" + TabTitles.OVERLAY
195
- elif num_snapshots == 1:
196
- return TabTitles.SNAPSHOT + "(" + str(num_snapshots) + ")" + "/" + TabTitles.OVERLAY
197
- elif num_snapshots >= 2:
198
- return TabTitles.SNAPSHOTS + "(" + str(num_snapshots) + ")" "/" + TabTitles.OVERLAY
199
+ if num_snapshots <= 0:
200
+ return TabTitles.STATE_MANAGEMENT
201
+ else:
202
+ return f"{TabTitles.STATE_MANAGEMENT}({num_snapshots})"
199
203
  except libvirt.libvirtError:
200
204
  pass # Domain might be transient or invalid
201
- return TabTitles.SNAPSHOT + "/" + TabTitles.OVERLAY
205
+ return TabTitles.STATE_MANAGEMENT
202
206
 
203
207
  def update_snapshot_tab_title(self, num_snapshots: int = -1) -> None:
204
208
  """Updates the snapshot tab title."""
@@ -342,7 +346,7 @@ class VMCard(Static):
342
346
  """Updates the tooltip for the VM name using Markdown."""
343
347
  if not self.display or not self.ui or "vmname" not in self.ui:
344
348
  return
345
-
349
+
346
350
  uuid = self.internal_id
347
351
  if not uuid:
348
352
  return
@@ -388,7 +392,7 @@ class VMCard(Static):
388
392
  if ips:
389
393
  ip_display = ", ".join(ips)
390
394
 
391
- cpu_model_display = f" ({self.cpu_model})" if self.cpu_model else ""
395
+ cpu_model_display = f" {self.cpu_model}" if self.cpu_model else ""
392
396
 
393
397
  tooltip_md = generate_tooltip_markdown(
394
398
  uuid=uuid_display,
@@ -397,7 +401,7 @@ class VMCard(Static):
397
401
  ip=ip_display,
398
402
  boot=self.boot_device or "N/A",
399
403
  cpu=self.cpu,
400
- cpu_model=self.cpu_model or "",
404
+ cpu_model=cpu_model_display or "",
401
405
  memory=self.memory
402
406
  )
403
407
 
@@ -576,8 +580,7 @@ class VMCard(Static):
576
580
  logging.warning(f"Could not find #info-container on VMCard {self.name} when switching to detailed view.")
577
581
  except Exception as e:
578
582
  # Catch-all for potential mounting errors (e.g. already mounted elsewhere?)
579
- logging.warning(f"Error restoring collapsible in detailed view: {e}")
580
-
583
+ logging.warning(f"Error restoring collapsible in detailed view: {e}")
581
584
 
582
585
  # Ensure sparklines visibility is correct
583
586
  self.watch_stats_view_mode(self.stats_view_mode, self.stats_view_mode)
@@ -890,6 +893,7 @@ class VMCard(Static):
890
893
 
891
894
  self.ui["start"].display = is_stopped
892
895
  self.ui["shutdown"].display = is_running or is_blocked
896
+ self.ui["hibernate"].display = is_running or is_blocked
893
897
  self.ui["stop"].display = is_running or is_paused or is_pmsuspended or is_blocked
894
898
  self.ui["delete"].display = is_running or is_paused or is_stopped or is_pmsuspended or is_blocked
895
899
  self.ui["clone"].display = is_stopped
@@ -993,7 +997,7 @@ class VMCard(Static):
993
997
 
994
998
  def update_ui():
995
999
  self._update_slow_buttons(snapshot_summary, has_overlay)
996
-
1000
+
997
1001
  try:
998
1002
  self.app.call_from_thread(update_ui)
999
1003
  except RuntimeError:
@@ -1065,12 +1069,14 @@ class VMCard(Static):
1065
1069
 
1066
1070
  button_handlers = {
1067
1071
  "shutdown": self._handle_shutdown_button,
1072
+ "hibernate": self._handle_hibernate_button,
1068
1073
  "stop": self._handle_stop_button,
1069
1074
  "pause": self._handle_pause_button,
1070
1075
  "resume": self._handle_resume_button,
1071
1076
  "xml": self._handle_xml_button,
1072
1077
  "connect": self._handle_connect_button,
1073
1078
  "web_console": self._handle_web_console_button,
1079
+ "tmux_console": self._handle_tmux_console_button,
1074
1080
  "snapshot_take": self._handle_snapshot_take_button,
1075
1081
  "snapshot_restore": self._handle_snapshot_restore_button,
1076
1082
  "snapshot_delete": self._handle_snapshot_delete_button,
@@ -1243,6 +1249,27 @@ class VMCard(Static):
1243
1249
  if self.status in (StatusText.RUNNING, StatusText.PAUSED):
1244
1250
  self.post_message(VmActionRequest(self.internal_id, VmAction.STOP))
1245
1251
 
1252
+ def _handle_hibernate_button(self, event: Button.Pressed) -> None:
1253
+ """Handles the save button press."""
1254
+ logging.info(f"Attempting to save (hibernate) VM: {self.name}")
1255
+
1256
+ def do_save():
1257
+ self.stop_background_activities()
1258
+ self.app.vm_service.suppress_vm_events(self.internal_id)
1259
+ try:
1260
+ hibernate_vm(self.vm)
1261
+ self.app.call_from_thread(self.app.show_success_message, SuccessMessages.VM_SAVED_TEMPLATE.format(vm_name=self.name))
1262
+ self.app.vm_service.invalidate_vm_state_cache(self.internal_id)
1263
+ self.app.call_from_thread(setattr, self, 'status', StatusText.STOPPED)
1264
+ self.app.call_from_thread(self.update_button_layout)
1265
+ except Exception as e:
1266
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.ERROR_ON_VM_DURING_ACTION.format(vm_name=self.name, action='save', error=e))
1267
+ finally:
1268
+ self.app.vm_service.unsuppress_vm_events(self.internal_id)
1269
+
1270
+ if self.status in (StatusText.RUNNING, StatusText.PAUSED):
1271
+ self.app.worker_manager.run(do_save, name=f"save_{self.internal_id}")
1272
+
1246
1273
  def stop_background_activities(self):
1247
1274
  """ Stop background activities before action """
1248
1275
  with self._timer_lock:
@@ -1324,7 +1351,7 @@ class VMCard(Static):
1324
1351
  self.app.refresh_vm_list()
1325
1352
  except libvirt.libvirtError as e:
1326
1353
  self.app.show_error_message(ErrorMessages.INVALID_XML_TEMPLATE.format(vm_name=self.name, error=e))
1327
- logging.error(error_msg)
1354
+ logging.error(e)
1328
1355
  else:
1329
1356
  self.app.show_success_message(SuccessMessages.NO_XML_CHANGES)
1330
1357
 
@@ -1430,6 +1457,50 @@ class VMCard(Static):
1430
1457
  else:
1431
1458
  self.app.worker_manager.run(worker, name=f"start_console_{self.vm.name()}")
1432
1459
 
1460
+ def _handle_tmux_console_button(self, event: Button.Pressed) -> None:
1461
+ """Handles the text console button press by opening a new tmux window."""
1462
+ logging.info(f"Attempting to open text console for VM: {self.name}")
1463
+
1464
+ # Check if running in tmux
1465
+ if not os.environ.get("TMUX"):
1466
+ self.app.show_error_message("This feature requires running inside tmux.")
1467
+ return
1468
+
1469
+ try:
1470
+ # Use cached values to avoid libvirt calls where possible
1471
+ uri = self.app.vm_service.get_uri_for_connection(self.conn)
1472
+ if not uri:
1473
+ uri = self.conn.getURI()
1474
+
1475
+ # Get proper domain name
1476
+ _, domain_name = self.app.vm_service.get_vm_identity(self.vm, self.conn)
1477
+
1478
+ # Construct command
1479
+ # tmux new-window -n "Console: <vm_name>" "virsh -c <uri> console <vm_name>; read"
1480
+ help_msg = (
1481
+ "echo '---------------------------------------------------------'; "
1482
+ "echo 'Tmux Navigation Help:'; "
1483
+ "echo ' Ctrl+B N or P - Move to the next or previous window.'; "
1484
+ "echo ' Ctrl+B W - Open a panel to navigate across windows in multiple sessions.'; "
1485
+ "echo ' Ctrl+] - Close the current view.'; "
1486
+ "echo ' Ctrl+B ? - View all keybindings. Press Q to exit.';"
1487
+ "echo '---------------------------------------------------------'; "
1488
+ "echo 'Starting console...'; sleep 1;"
1489
+ )
1490
+ cmd = [
1491
+ "tmux", "new-window",
1492
+ "-n", f"{domain_name}",
1493
+ f"{help_msg} virsh -c {uri} console {domain_name}; echo '\nConsole session ended. Press Enter to close window.'; read"
1494
+ ]
1495
+
1496
+ logging.info(f"Launching tmux console: {' '.join(cmd)}")
1497
+ subprocess.Popen(cmd)
1498
+ self.app.show_quick_message(f"Opened console for {domain_name}")
1499
+
1500
+ except Exception as e:
1501
+ logging.error(f"Failed to open tmux console: {e}")
1502
+ self.app.show_error_message(f"Failed to open console: {e}")
1503
+
1433
1504
  def _handle_snapshot_take_button(self, event: Button.Pressed) -> None:
1434
1505
  """Handles the snapshot take button press."""
1435
1506
  logging.info(f"Attempting to take snapshot for VM: {self.name}")
@@ -1922,8 +1993,6 @@ class VMCard(Static):
1922
1993
  finally:
1923
1994
  self.app.vm_service.unsuppress_vm_events(internal_id)
1924
1995
 
1925
- num_snapshots = self.vm.snapshotNum(0)
1926
-
1927
1996
  def on_confirm_rename(confirmed: bool, delete_snapshots=False) -> None:
1928
1997
  if confirmed:
1929
1998
  do_rename()
@@ -1960,10 +2029,10 @@ class VMCard(Static):
1960
2029
  def get_details_worker():
1961
2030
  try:
1962
2031
  result = self.app.vm_service.get_vm_details(
1963
- active_uris,
1964
- uuid,
1965
- domain=vm_obj,
1966
- conn=conn_obj,
2032
+ active_uris,
2033
+ uuid,
2034
+ domain=vm_obj,
2035
+ conn=conn_obj,
1967
2036
  cached_ips=cached_ips
1968
2037
  )
1969
2038
 
@@ -1975,22 +2044,27 @@ class VMCard(Static):
1975
2044
 
1976
2045
  vm_info, domain, conn_for_domain = result
1977
2046
 
1978
- def on_detail_modal_dismissed(res):
2047
+ def on_detail_modal_dismissed(_=None):
1979
2048
  self.post_message(VmCardUpdateRequest(self.internal_id))
1980
2049
  self._perform_tooltip_update()
1981
2050
 
1982
2051
  self.app.push_screen(
1983
- VMDetailModal(vm_name, vm_info, domain, conn_for_domain, self.app.vm_service.invalidate_vm_state_cache),
2052
+ VMDetailModal(
2053
+ vm_name,
2054
+ vm_info,
2055
+ domain,
2056
+ conn_for_domain,
2057
+ self.app.vm_service.invalidate_vm_state_cache),
1984
2058
  on_detail_modal_dismissed
1985
- )
2059
+ )
1986
2060
 
1987
2061
  self.app.call_from_thread(show_details)
1988
2062
 
1989
2063
  except Exception as e:
1990
- def show_error():
2064
+ def show_error(error_instance):
1991
2065
  loading_modal.dismiss()
1992
- self.app.show_error_message(ErrorMessages.ERROR_GETTING_VM_DETAILS_TEMPLATE.format(vm_name=vm_name, error=e))
1993
- self.app.call_from_thread(show_error)
2066
+ self.app.show_error_message(ErrorMessages.ERROR_GETTING_VM_DETAILS_TEMPLATE.format(vm_name=vm_name, error=error_instance))
2067
+ self.app.call_from_thread(show_error, e)
1994
2068
 
1995
2069
  self.app.worker_manager.run(get_details_worker, name=f"get_details_{uuid}")
1996
2070
 
@@ -2003,27 +2077,16 @@ class VMCard(Static):
2003
2077
  self.app.show_error_message(ErrorMessages.SELECT_AT_LEAST_TWO_SERVERS_FOR_MIGRATION)
2004
2078
  return
2005
2079
 
2006
- selected_vm_uuids = self.app.selected_vm_uuids
2080
+ selected_vm_uuids = list(self.app.selected_vm_uuids)
2007
2081
  selected_vms = []
2008
2082
  if selected_vm_uuids:
2083
+ found_domains_dict = self.app.vm_service.find_domains_by_uuids(self.app.active_uris, selected_vm_uuids)
2009
2084
  for uuid in selected_vm_uuids:
2010
- #Use cached domain lookup instead of iterating all URIs
2011
- with self.app.vm_service._cache_lock:
2012
- domain = self.app.vm_service._domain_cache.get(uuid)
2013
-
2085
+ domain = found_domains_dict.get(uuid)
2014
2086
  if domain:
2015
- try:
2016
- # Verify domain is still valid
2017
- domain.info()
2018
- selected_vms.append(domain)
2019
- found_domain = True
2020
- except libvirt.libvirtError:
2021
- found_domain = False
2087
+ selected_vms.append(domain)
2022
2088
  else:
2023
- found_domain = False
2024
- if not vm_info:
2025
- self.app.show_error_message(ErrorMessages.SELECTED_VM_NOT_FOUND_ON_ACTIVE_SERVER_TEMPLATE.format(uuid=uuid))
2026
-
2089
+ self.app.show_error_message(ErrorMessages.SELECTED_VM_NOT_FOUND_ON_ACTIVE_SERVER_TEMPLATE.format(uuid=uuid))
2027
2090
  if not selected_vms:
2028
2091
  selected_vms = [self.vm]
2029
2092
 
@@ -2053,7 +2116,7 @@ class VMCard(Static):
2053
2116
  state, _ = state_tuple
2054
2117
  if state in [libvirt.VIR_DOMAIN_RUNNING, libvirt.VIR_DOMAIN_PAUSED]:
2055
2118
  active_vms.append(vm)
2056
- except:
2119
+ except Exception:
2057
2120
  # Fallback to isActive() if cache lookup fails
2058
2121
  if vm.isActive():
2059
2122
  active_vms.append(vm)
@@ -2118,7 +2181,7 @@ class VMCard(Static):
2118
2181
  try:
2119
2182
  # Use vm_service to get XML (handles caching)
2120
2183
  self.app.vm_service._get_domain_xml(self.vm, internal_id=self.internal_id)
2121
-
2184
+
2122
2185
  # Update tooltip on main thread
2123
2186
  self.app.call_from_thread(self._perform_tooltip_update)
2124
2187
  self.app.call_from_thread(self.app.show_quick_message, f"Info refreshed for {self.name}")
@@ -17,7 +17,7 @@ from urllib.parse import urlparse
17
17
 
18
18
  import libvirt
19
19
 
20
- from .constants import AppInfo
20
+ from .constants import AppInfo, ErrorMessages, SuccessMessages
21
21
  from .events import VmCardUpdateRequest
22
22
  from .config import load_config, get_log_path
23
23
  from .vm_queries import get_vm_graphics_info