virtui-manager 1.1.6__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.
Files changed (62) hide show
  1. {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/METADATA +1 -1
  2. virtui_manager-1.4.0.dist-info/RECORD +76 -0
  3. vmanager/constants.py +739 -108
  4. vmanager/dialog.css +24 -0
  5. vmanager/firmware_manager.py +4 -1
  6. vmanager/i18n.py +32 -0
  7. vmanager/libvirt_utils.py +132 -3
  8. vmanager/locales/de/LC_MESSAGES/virtui-manager.mo +0 -0
  9. vmanager/locales/de/LC_MESSAGES/virtui-manager.po +3158 -0
  10. vmanager/locales/fr/LC_MESSAGES/virtui-manager.mo +0 -0
  11. vmanager/locales/fr/LC_MESSAGES/virtui-manager.po +3155 -0
  12. vmanager/locales/it/LC_MESSAGES/virtui-manager.mo +0 -0
  13. vmanager/locales/it/LC_MESSAGES/virtui-manager.po +3132 -0
  14. vmanager/locales/virtui-manager.pot +3033 -0
  15. vmanager/modals/bulk_modals.py +13 -12
  16. vmanager/modals/cache_stats_modal.py +6 -5
  17. vmanager/modals/capabilities_modal.py +133 -0
  18. vmanager/modals/config_modal.py +25 -24
  19. vmanager/modals/cpu_mem_pc_modals.py +22 -21
  20. vmanager/modals/custom_migration_modal.py +10 -9
  21. vmanager/modals/disk_pool_modals.py +60 -59
  22. vmanager/modals/host_dashboard_modal.py +137 -0
  23. vmanager/modals/host_stats.py +199 -0
  24. vmanager/modals/howto_disk_modal.py +2 -1
  25. vmanager/modals/howto_network_modal.py +2 -1
  26. vmanager/modals/howto_overlay_modal.py +2 -1
  27. vmanager/modals/howto_ssh_modal.py +2 -1
  28. vmanager/modals/howto_virtiofs_modal.py +2 -1
  29. vmanager/modals/input_modals.py +11 -10
  30. vmanager/modals/log_modal.py +2 -1
  31. vmanager/modals/migration_modals.py +21 -19
  32. vmanager/modals/network_modals.py +45 -36
  33. vmanager/modals/provisioning_modals.py +56 -56
  34. vmanager/modals/select_server_modals.py +8 -7
  35. vmanager/modals/selection_modals.py +7 -6
  36. vmanager/modals/server_modals.py +24 -23
  37. vmanager/modals/server_prefs_modals.py +78 -71
  38. vmanager/modals/utils_modals.py +10 -9
  39. vmanager/modals/virsh_modals.py +3 -2
  40. vmanager/modals/virtiofs_modals.py +6 -5
  41. vmanager/modals/vm_type_info_modal.py +2 -1
  42. vmanager/modals/vmanager_modals.py +19 -19
  43. vmanager/modals/vmcard_dialog.py +57 -57
  44. vmanager/modals/vmdetails_modals.py +115 -123
  45. vmanager/modals/xml_modals.py +3 -2
  46. vmanager/network_manager.py +4 -1
  47. vmanager/storage_manager.py +157 -39
  48. vmanager/utils.py +54 -7
  49. vmanager/vm_actions.py +48 -24
  50. vmanager/vm_migration.py +4 -1
  51. vmanager/vm_queries.py +67 -25
  52. vmanager/vm_service.py +8 -5
  53. vmanager/vmanager.css +55 -1
  54. vmanager/vmanager.py +247 -120
  55. vmanager/vmcard.css +3 -1
  56. vmanager/vmcard.py +270 -205
  57. vmanager/webconsole_manager.py +22 -22
  58. virtui_manager-1.1.6.dist-info/RECORD +0 -65
  59. {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/WHEEL +0 -0
  60. {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/entry_points.txt +0 -0
  61. {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/licenses/LICENSE +0 -0
  62. {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/top_level.txt +0 -0
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,10 +51,12 @@ 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
- ButtonLabels, ButtonIds, TabTitles, StatusText,
57
- SparklineLabels, ErrorMessages, DialogMessages, VmAction
57
+ ButtonLabels, TabTitles, StatusText,
58
+ SparklineLabels, ErrorMessages, DialogMessages, VmAction,
59
+ WarningMessages, SuccessMessages
58
60
  )
59
61
 
60
62
  class VMCardActions(Static):
@@ -63,29 +65,31 @@ class VMCardActions(Static):
63
65
  super().__init__()
64
66
 
65
67
  def compose(self):
66
- self.card.ui[ButtonIds.START] = Button(ButtonLabels.START, id=ButtonIds.START, variant="success")
67
- self.card.ui[ButtonIds.SHUTDOWN] = Button(ButtonLabels.SHUTDOWN, id=ButtonIds.SHUTDOWN, variant="primary")
68
- self.card.ui[ButtonIds.STOP] = Button(ButtonLabels.FORCE_OFF, id=ButtonIds.STOP, variant="error")
69
- self.card.ui[ButtonIds.PAUSE] = Button(ButtonLabels.PAUSE, id=ButtonIds.PAUSE, variant="primary")
70
- self.card.ui[ButtonIds.RESUME] = Button(ButtonLabels.RESUME, id=ButtonIds.RESUME, variant="success")
71
- self.card.ui[ButtonIds.CONFIGURE_BUTTON] = Button(ButtonLabels.CONFIGURE, id=ButtonIds.CONFIGURE_BUTTON, variant="primary")
72
- self.card.ui[ButtonIds.WEB_CONSOLE] = Button(ButtonLabels.WEB_CONSOLE, id=ButtonIds.WEB_CONSOLE, variant="default")
73
- self.card.ui[ButtonIds.CONNECT] = Button(ButtonLabels.CONNECT, id=ButtonIds.CONNECT, variant="default")
68
+ self.card.ui["start"] = Button(ButtonLabels.START, id="start", variant="success")
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")
71
+ self.card.ui["stop"] = Button(ButtonLabels.FORCE_OFF, id="stop", variant="error")
72
+ self.card.ui["pause"] = Button(ButtonLabels.PAUSE, id="pause", variant="primary")
73
+ self.card.ui["resume"] = Button(ButtonLabels.RESUME, id="resume", variant="success")
74
+ self.card.ui["configure-button"] = Button(ButtonLabels.CONFIGURE, id="configure-button", variant="primary")
75
+ self.card.ui["web_console"] = Button(ButtonLabels.WEB_CONSOLE, id="web_console", variant="default")
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")
74
78
 
75
- self.card.ui[ButtonIds.SNAPSHOT_TAKE] = Button(ButtonLabels.SNAPSHOT, id=ButtonIds.SNAPSHOT_TAKE, variant="primary")
76
- self.card.ui[ButtonIds.SNAPSHOT_RESTORE] = Button(ButtonLabels.RESTORE_SNAPSHOT, id=ButtonIds.SNAPSHOT_RESTORE, variant="primary")
77
- self.card.ui[ButtonIds.SNAPSHOT_DELETE] = Button(ButtonLabels.DELETE_SNAPSHOT, id=ButtonIds.SNAPSHOT_DELETE, variant="error")
79
+ self.card.ui["snapshot_take"] = Button(ButtonLabels.SNAPSHOT, id="snapshot_take", variant="primary")
80
+ self.card.ui["snapshot_restore"] = Button(ButtonLabels.RESTORE_SNAPSHOT, id="snapshot_restore", variant="primary")
81
+ self.card.ui["snapshot_delete"] = Button(ButtonLabels.DELETE_SNAPSHOT, id="snapshot_delete", variant="error")
78
82
 
79
- self.card.ui[ButtonIds.DELETE] = Button(ButtonLabels.DELETE, id=ButtonIds.DELETE, variant="error", classes="delete-button")
80
- self.card.ui[ButtonIds.CLONE] = Button(ButtonLabels.CLONE, id=ButtonIds.CLONE, classes="clone-button")
81
- self.card.ui[ButtonIds.MIGRATION] = Button(ButtonLabels.MIGRATION, id=ButtonIds.MIGRATION, variant="primary", classes="migration-button")
82
- self.card.ui[ButtonIds.XML] = Button(ButtonLabels.VIEW_XML, id=ButtonIds.XML)
83
- self.card.ui[ButtonIds.RENAME_BUTTON] = Button(ButtonLabels.RENAME, id=ButtonIds.RENAME_BUTTON, variant="primary", classes="rename-button")
83
+ self.card.ui["delete"] = Button(ButtonLabels.DELETE, id="delete", variant="error", classes="delete-button")
84
+ self.card.ui["clone"] = Button(ButtonLabels.CLONE, id="clone", classes="clone-button")
85
+ self.card.ui["migration"] = Button(ButtonLabels.MIGRATION, id="migration", variant="primary", classes="migration-button")
86
+ self.card.ui["xml"] = Button(ButtonLabels.VIEW_XML, id="xml")
87
+ self.card.ui["rename-button"] = Button(ButtonLabels.RENAME, id="rename-button", variant="primary", classes="rename-button")
84
88
 
85
- self.card.ui[ButtonIds.CREATE_OVERLAY] = Button(ButtonLabels.CREATE_OVERLAY, id=ButtonIds.CREATE_OVERLAY, variant="primary")
86
- self.card.ui[ButtonIds.COMMIT_DISK] = Button(ButtonLabels.COMMIT_DISK, id=ButtonIds.COMMIT_DISK, variant="error")
87
- self.card.ui[ButtonIds.DISCARD_OVERLAY] = Button(ButtonLabels.DISCARD_OVERLAY, id=ButtonIds.DISCARD_OVERLAY, variant="error")
88
- self.card.ui[ButtonIds.SNAP_OVERLAY_HELP] = Button(ButtonLabels.SNAP_OVERLAY_HELP, id=ButtonIds.SNAP_OVERLAY_HELP, variant="default")
89
+ self.card.ui["create_overlay"] = Button(ButtonLabels.CREATE_OVERLAY, id="create_overlay", variant="primary")
90
+ self.card.ui["commit_disk"] = Button(ButtonLabels.COMMIT_DISK, id="commit_disk", variant="error")
91
+ self.card.ui["discard_overlay"] = Button(ButtonLabels.DISCARD_OVERLAY, id="discard_overlay", variant="error")
92
+ self.card.ui["snap_overlay_help"] = Button(ButtonLabels.SNAP_OVERLAY_HELP, id="snap_overlay_help", variant="default")
89
93
 
90
94
  self.card.ui["tabbed_content"] = TabbedContent(id="button-container")
91
95
 
@@ -93,37 +97,40 @@ class VMCardActions(Static):
93
97
  with TabPane(TabTitles.MANAGE, id="manage-tab"):
94
98
  with Horizontal():
95
99
  with Vertical():
96
- yield self.card.ui[ButtonIds.START]
97
- yield self.card.ui[ButtonIds.SHUTDOWN]
98
- yield self.card.ui[ButtonIds.STOP]
99
- yield self.card.ui[ButtonIds.PAUSE]
100
- yield self.card.ui[ButtonIds.RESUME]
100
+ yield self.card.ui["start"]
101
+ yield self.card.ui["shutdown"]
102
+ yield self.card.ui["stop"]
103
+ yield self.card.ui["pause"]
104
+ yield self.card.ui["resume"]
101
105
  with Vertical():
102
- yield self.card.ui[ButtonIds.CONFIGURE_BUTTON]
103
- yield self.card.ui[ButtonIds.WEB_CONSOLE]
104
- yield self.card.ui[ButtonIds.CONNECT]
105
- with TabPane(self.card._get_snapshot_tab_title(num_snapshots=0), id="snapshot-tab"):
106
+ yield self.card.ui["web_console"]
107
+ yield self.card.ui["connect"]
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"):
106
111
  with Horizontal():
107
112
  with Vertical():
108
- yield self.card.ui[ButtonIds.SNAPSHOT_TAKE]
109
- yield self.card.ui[ButtonIds.SNAPSHOT_RESTORE]
110
- yield self.card.ui[ButtonIds.SNAPSHOT_DELETE]
113
+ yield self.card.ui["snapshot_take"]
114
+ yield self.card.ui["snapshot_restore"]
115
+ yield self.card.ui["snapshot_delete"]
111
116
  with Vertical():
112
- yield self.card.ui[ButtonIds.CREATE_OVERLAY]
113
- yield self.card.ui[ButtonIds.COMMIT_DISK]
114
- yield self.card.ui[ButtonIds.DISCARD_OVERLAY]
115
- yield self.card.ui[ButtonIds.SNAP_OVERLAY_HELP]
117
+ yield self.card.ui["hibernate"]
118
+ yield self.card.ui["create_overlay"]
119
+ yield self.card.ui["commit_disk"]
120
+ yield self.card.ui["discard_overlay"]
121
+ yield self.card.ui["snap_overlay_help"]
116
122
  with TabPane(TabTitles.OTHER, id="special-tab"):
117
123
  with Horizontal():
118
124
  with Vertical():
119
- yield self.card.ui[ButtonIds.DELETE]
125
+ yield self.card.ui["delete"]
120
126
  yield Static(classes="button-separator")
121
- yield self.card.ui[ButtonIds.CLONE]
122
- yield self.card.ui[ButtonIds.MIGRATION]
127
+ yield self.card.ui["clone"]
128
+ yield self.card.ui["migration"]
123
129
  with Vertical():
124
- yield self.card.ui[ButtonIds.XML]
130
+ yield self.card.ui["configure-button"]
131
+ yield self.card.ui["xml"]
125
132
  yield Static(classes="button-separator")
126
- yield self.card.ui[ButtonIds.RENAME_BUTTON]
133
+ yield self.card.ui["rename-button"]
127
134
 
128
135
 
129
136
  class VMCard(Static):
@@ -183,21 +190,19 @@ class VMCard(Static):
183
190
  def _get_snapshot_tab_title(self, num_snapshots: int = -1) -> str:
184
191
  """Get snapshot tab title. Pass num_snapshots to avoid blocking libvirt call."""
185
192
  if num_snapshots == -1:
186
- # If no count provided, don't fetch it here to avoid blocking.
187
- # For now, return default if we can't get it cheaply.
188
- 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
189
196
 
190
197
  if self.vm:
191
198
  try:
192
- if num_snapshots == 0:
193
- return TabTitles.SNAPSHOT + "/" + TabTitles.OVERLAY
194
- elif num_snapshots == 1:
195
- return TabTitles.SNAPSHOT + "(" + str(num_snapshots) + ")" + "/" + TabTitles.OVERLAY
196
- elif num_snapshots >= 2:
197
- 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})"
198
203
  except libvirt.libvirtError:
199
204
  pass # Domain might be transient or invalid
200
- return TabTitles.SNAPSHOT + "/" + TabTitles.OVERLAY
205
+ return TabTitles.STATE_MANAGEMENT
201
206
 
202
207
  def update_snapshot_tab_title(self, num_snapshots: int = -1) -> None:
203
208
  """Updates the snapshot tab title."""
@@ -228,7 +233,7 @@ class VMCard(Static):
228
233
  self.webc_status_indicator = new_indicator
229
234
 
230
235
  # Update button label and style
231
- web_console_button = self.ui.get(ButtonIds.WEB_CONSOLE)
236
+ web_console_button = self.ui.get("web_console")
232
237
  if web_console_button:
233
238
  if webc_is_running:
234
239
  web_console_button.label = "Show Console"
@@ -249,7 +254,7 @@ class VMCard(Static):
249
254
  def compose(self):
250
255
  self.ui["checkbox"] = Checkbox("", id="vm-select-checkbox", classes="vm-select-checkbox", value=self.is_selected, tooltip="Select VM")
251
256
  self.ui["vmname"] = Static(self._get_vm_display_name(), id="vmname", classes="vmname")
252
- self.ui["status"] = Static(f"{self.status}{self.webc_status_indicator}", id="status", classes=self.status.lower())
257
+ self.ui["status"] = Static(f"{self.status}{self.webc_status_indicator}", id="status")
253
258
 
254
259
  # Create all sparkline components
255
260
  self.ui["cpu_label"] = Static("", classes="sparkline-label")
@@ -341,7 +346,7 @@ class VMCard(Static):
341
346
  """Updates the tooltip for the VM name using Markdown."""
342
347
  if not self.display or not self.ui or "vmname" not in self.ui:
343
348
  return
344
-
349
+
345
350
  uuid = self.internal_id
346
351
  if not uuid:
347
352
  return
@@ -387,7 +392,7 @@ class VMCard(Static):
387
392
  if ips:
388
393
  ip_display = ", ".join(ips)
389
394
 
390
- 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 ""
391
396
 
392
397
  tooltip_md = generate_tooltip_markdown(
393
398
  uuid=uuid_display,
@@ -396,7 +401,7 @@ class VMCard(Static):
396
401
  ip=ip_display,
397
402
  boot=self.boot_device or "N/A",
398
403
  cpu=self.cpu,
399
- cpu_model=self.cpu_model or "",
404
+ cpu_model=cpu_model_display or "",
400
405
  memory=self.memory
401
406
  )
402
407
 
@@ -575,8 +580,7 @@ class VMCard(Static):
575
580
  logging.warning(f"Could not find #info-container on VMCard {self.name} when switching to detailed view.")
576
581
  except Exception as e:
577
582
  # Catch-all for potential mounting errors (e.g. already mounted elsewhere?)
578
- logging.warning(f"Error restoring collapsible in detailed view: {e}")
579
-
583
+ logging.warning(f"Error restoring collapsible in detailed view: {e}")
580
584
 
581
585
  # Ensure sparklines visibility is correct
582
586
  self.watch_stats_view_mode(self.stats_view_mode, self.stats_view_mode)
@@ -864,8 +868,7 @@ class VMCard(Static):
864
868
  self._update_fast_buttons()
865
869
  self._update_webc_status()
866
870
 
867
- # Trigger background fetch for heavy data (snapshots, overlays) only if actions are visible
868
- if self.ui.get(ButtonIds.RENAME_BUTTON):
871
+ if self.ui.get("rename-button"):
869
872
  # Check if collapsible is expanded before fetching heavy data
870
873
  collapsible = self.ui.get("collapsible")
871
874
  if collapsible and not collapsible.collapsed:
@@ -885,31 +888,32 @@ class VMCard(Static):
885
888
  is_pmsuspended = self.status == StatusText.PMSUSPENDED
886
889
  is_blocked = self.status == StatusText.BLOCKED
887
890
 
888
- if not self.ui.get(ButtonIds.RENAME_BUTTON):
891
+ if not self.ui.get("rename-button"):
889
892
  return
890
893
 
891
- self.ui[ButtonIds.START].display = is_stopped
892
- self.ui[ButtonIds.SHUTDOWN].display = is_running or is_blocked
893
- self.ui[ButtonIds.STOP].display = is_running or is_paused or is_pmsuspended or is_blocked
894
- self.ui[ButtonIds.DELETE].display = is_running or is_paused or is_stopped or is_pmsuspended or is_blocked
895
- self.ui[ButtonIds.CLONE].display = is_stopped
896
- self.ui[ButtonIds.MIGRATION].display = not is_loading
897
- self.ui[ButtonIds.RENAME_BUTTON].display = is_stopped
898
- self.ui[ButtonIds.PAUSE].display = is_running
899
- self.ui[ButtonIds.RESUME].display = is_paused or is_pmsuspended
900
- self.ui[ButtonIds.CONNECT].display = self.app.r_viewer_available
901
- self.ui[ButtonIds.WEB_CONSOLE].display = (is_running or is_paused or is_blocked)
902
- self.ui[ButtonIds.CONFIGURE_BUTTON].display = not is_loading
903
- self.ui[ButtonIds.SNAP_OVERLAY_HELP].display = not is_loading
904
- self.ui[ButtonIds.SNAPSHOT_TAKE].display = not is_loading #is_running or is_paused
905
- self.ui[ButtonIds.SNAPSHOT_RESTORE].display = not is_running and not is_loading and not is_blocked
906
-
907
- xml_button = self.ui[ButtonIds.XML]
894
+ self.ui["start"].display = is_stopped
895
+ self.ui["shutdown"].display = is_running or is_blocked
896
+ self.ui["hibernate"].display = is_running or is_blocked
897
+ self.ui["stop"].display = is_running or is_paused or is_pmsuspended or is_blocked
898
+ self.ui["delete"].display = is_running or is_paused or is_stopped or is_pmsuspended or is_blocked
899
+ self.ui["clone"].display = is_stopped
900
+ self.ui["migration"].display = not is_loading
901
+ self.ui["rename-button"].display = is_stopped
902
+ self.ui["pause"].display = is_running
903
+ self.ui["resume"].display = is_paused or is_pmsuspended
904
+ self.ui["connect"].display = self.app.r_viewer_available
905
+ self.ui["web_console"].display = (is_running or is_paused or is_blocked)
906
+ self.ui["configure-button"].display = not is_loading
907
+ self.ui["snap_overlay_help"].display = not is_loading
908
+ self.ui["snapshot_take"].display = not is_loading #is_running or is_paused
909
+ self.ui["snapshot_restore"].display = not is_running and not is_loading and not is_blocked
910
+
911
+ xml_button = self.ui["xml"]
908
912
  if is_stopped:
909
- xml_button.label = "Edit XML"
913
+ xml_button.label = ButtonLabels.EDIT_XML
910
914
  self.stats_view_mode = "resources"
911
915
  else:
912
- xml_button.label = "View XML"
916
+ xml_button.label = ButtonLabels.VIEW_XML
913
917
  xml_button.display = not is_loading
914
918
 
915
919
  def _fetch_actions_state_worker(self):
@@ -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:
@@ -1004,7 +1008,7 @@ class VMCard(Static):
1004
1008
 
1005
1009
  def _update_slow_buttons(self, snapshot_summary: dict, has_overlay: bool):
1006
1010
  """Updates buttons that rely on heavy state."""
1007
- if not self.ui.get(ButtonIds.RENAME_BUTTON):
1011
+ if not self.ui.get("rename-button"):
1008
1012
  return
1009
1013
 
1010
1014
  snapshot_count = snapshot_summary.get('count', 0)
@@ -1031,12 +1035,12 @@ class VMCard(Static):
1031
1035
 
1032
1036
  has_snapshots = snapshot_count > 0
1033
1037
 
1034
- self.ui[ButtonIds.SNAPSHOT_RESTORE].display = has_snapshots and not is_running and not is_loading and not is_blocked
1035
- self.ui[ButtonIds.SNAPSHOT_DELETE].display = has_snapshots
1038
+ self.ui["snapshot_restore"].display = has_snapshots and not is_running and not is_loading and not is_blocked
1039
+ self.ui["snapshot_delete"].display = has_snapshots
1036
1040
 
1037
- self.ui[ButtonIds.COMMIT_DISK].display = (is_running or is_blocked) and has_overlay
1038
- self.ui[ButtonIds.DISCARD_OVERLAY].display = is_stopped and has_overlay
1039
- self.ui[ButtonIds.CREATE_OVERLAY].display = is_stopped and not has_overlay
1041
+ self.ui["commit_disk"].display = (is_running or is_blocked) and has_overlay
1042
+ self.ui["discard_overlay"].display = is_stopped and has_overlay
1043
+ self.ui["create_overlay"].display = is_stopped and not has_overlay
1040
1044
 
1041
1045
  self.update_snapshot_tab_title(snapshot_count)
1042
1046
 
@@ -1046,39 +1050,45 @@ class VMCard(Static):
1046
1050
  status_widget.remove_class("stopped", "running", "paused", "loading", "pmsuspended", "blocked")
1047
1051
  if self.status == StatusText.LOADING:
1048
1052
  status_widget.add_class("loading")
1053
+ elif self.status == StatusText.RUNNING:
1054
+ status_widget.add_class("running")
1055
+ elif self.status == StatusText.STOPPED:
1056
+ status_widget.add_class("stopped")
1057
+ elif self.status == StatusText.PAUSED:
1058
+ status_widget.add_class("paused")
1049
1059
  elif self.status == StatusText.PMSUSPENDED:
1050
1060
  status_widget.add_class("pmsuspended")
1051
1061
  elif self.status == StatusText.BLOCKED:
1052
1062
  status_widget.add_class("blocked")
1053
- else:
1054
- status_widget.add_class(self.status.lower())
1055
1063
 
1056
1064
  def on_button_pressed(self, event: Button.Pressed) -> None:
1057
1065
  """Handle button presses."""
1058
- if event.button.id == ButtonIds.START:
1066
+ if event.button.id == "start":
1059
1067
  self.post_message(VmActionRequest(self.internal_id, VmAction.START))
1060
1068
  return
1061
1069
 
1062
1070
  button_handlers = {
1063
- ButtonIds.SHUTDOWN: self._handle_shutdown_button,
1064
- ButtonIds.STOP: self._handle_stop_button,
1065
- ButtonIds.PAUSE: self._handle_pause_button,
1066
- ButtonIds.RESUME: self._handle_resume_button,
1067
- ButtonIds.XML: self._handle_xml_button,
1068
- ButtonIds.CONNECT: self._handle_connect_button,
1069
- ButtonIds.WEB_CONSOLE: self._handle_web_console_button,
1070
- ButtonIds.SNAPSHOT_TAKE: self._handle_snapshot_take_button,
1071
- ButtonIds.SNAPSHOT_RESTORE: self._handle_snapshot_restore_button,
1072
- ButtonIds.SNAPSHOT_DELETE: self._handle_snapshot_delete_button,
1073
- ButtonIds.DELETE: self._handle_delete_button,
1074
- ButtonIds.CLONE: self._handle_clone_button,
1075
- ButtonIds.MIGRATION: self._handle_migration_button,
1076
- ButtonIds.RENAME_BUTTON: self._handle_rename_button,
1077
- ButtonIds.CONFIGURE_BUTTON: self._handle_configure_button,
1078
- ButtonIds.CREATE_OVERLAY: self._handle_create_overlay,
1079
- ButtonIds.COMMIT_DISK: self._handle_commit_disk,
1080
- ButtonIds.DISCARD_OVERLAY: self._handle_discard_overlay,
1081
- ButtonIds.SNAP_OVERLAY_HELP: self._handle_overlay_help,
1071
+ "shutdown": self._handle_shutdown_button,
1072
+ "hibernate": self._handle_hibernate_button,
1073
+ "stop": self._handle_stop_button,
1074
+ "pause": self._handle_pause_button,
1075
+ "resume": self._handle_resume_button,
1076
+ "xml": self._handle_xml_button,
1077
+ "connect": self._handle_connect_button,
1078
+ "web_console": self._handle_web_console_button,
1079
+ "tmux_console": self._handle_tmux_console_button,
1080
+ "snapshot_take": self._handle_snapshot_take_button,
1081
+ "snapshot_restore": self._handle_snapshot_restore_button,
1082
+ "snapshot_delete": self._handle_snapshot_delete_button,
1083
+ "delete": self._handle_delete_button,
1084
+ "clone": self._handle_clone_button,
1085
+ "migration": self._handle_migration_button,
1086
+ "rename-button": self._handle_rename_button,
1087
+ "configure-button": self._handle_configure_button,
1088
+ "create_overlay": self._handle_create_overlay,
1089
+ "commit_disk": self._handle_commit_disk,
1090
+ "discard_overlay": self._handle_discard_overlay,
1091
+ "snap_overlay_help": self._handle_overlay_help,
1082
1092
  }
1083
1093
  handler = button_handlers.get(event.button.id)
1084
1094
  if handler:
@@ -1100,7 +1110,7 @@ class VMCard(Static):
1100
1110
  valid_disks = [d['path'] for d in disks if d.get('device_type') == 'disk']
1101
1111
 
1102
1112
  if not valid_disks:
1103
- self.app.show_error_message("No suitable disks found for overlay.")
1113
+ self.app.show_error_message(ErrorMessages.NO_SUITABLE_DISKS_FOR_OVERLAY)
1104
1114
  return
1105
1115
 
1106
1116
  target_disk = valid_disks[0]
@@ -1120,29 +1130,29 @@ class VMCard(Static):
1120
1130
  return
1121
1131
 
1122
1132
  if was_modified:
1123
- self.app.show_success_message(f"Input sanitized: '{overlay_name_raw}' changed to '{overlay_name}'")
1133
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=overlay_name_raw, sanitized_input=overlay_name))
1124
1134
 
1125
1135
  if not overlay_name:
1126
- self.app.show_error_message("Overlay volume name cannot be empty after sanitization.")
1136
+ self.app.show_error_message(ErrorMessages.OVERLAY_NAME_EMPTY_AFTER_SANITIZATION)
1127
1137
  return
1128
1138
 
1129
1139
  self.app.vm_service.suppress_vm_events(self.internal_id)
1130
1140
  try:
1131
1141
  create_external_overlay(self.vm, target_disk, overlay_name)
1132
- self.app.show_success_message(f"Overlay [b]{overlay_name}[/b] created and attached.")
1142
+ self.app.show_success_message(SuccessMessages.OVERLAY_CREATED.format(overlay_name=overlay_name))
1133
1143
  self.app.vm_service.invalidate_vm_state_cache(self.internal_id)
1134
1144
  self._boot_device_checked = False
1135
1145
  self.post_message(VmCardUpdateRequest(self.internal_id))
1136
1146
  self.update_button_layout()
1137
1147
  except Exception as e:
1138
- self.app.show_error_message(f"Error creating overlay: {e}")
1148
+ self.app.show_error_message(ErrorMessages.ERROR_CREATING_OVERLAY_TEMPLATE.format(error=e))
1139
1149
  finally:
1140
1150
  self.app.vm_service.unsuppress_vm_events(self.internal_id)
1141
1151
 
1142
1152
  self.app.push_screen(InputModal("Enter name for new overlay volume:", default_name, restrict=r"[a-zA-Z0-9_-]*"), on_name_input)
1143
1153
 
1144
1154
  except Exception as e:
1145
- self.app.show_error_message(f"Error preparing overlay creation: {e}")
1155
+ self.app.show_error_message(ErrorMessages.ERROR_PREPARING_OVERLAY_CREATION_TEMPLATE.format(error=e))
1146
1156
 
1147
1157
  def _handle_discard_overlay(self, event: Button.Pressed) -> None:
1148
1158
  """Handles the discard overlay button press."""
@@ -1150,7 +1160,7 @@ class VMCard(Static):
1150
1160
  overlay_disks = get_overlay_disks(self.vm)
1151
1161
 
1152
1162
  if not overlay_disks:
1153
- self.app.show_error_message("No overlay disks found.")
1163
+ self.app.show_error_message(ErrorMessages.NO_OVERLAY_DISKS_FOUND)
1154
1164
  return
1155
1165
 
1156
1166
  def proceed_with_discard(target_disk: str | None):
@@ -1162,13 +1172,13 @@ class VMCard(Static):
1162
1172
  self.app.vm_service.suppress_vm_events(self.internal_id)
1163
1173
  try:
1164
1174
  discard_overlay(self.vm, target_disk)
1165
- self.app.show_success_message(f"Overlay for [b]{target_disk}[/b] discarded and reverted to base image.")
1175
+ self.app.show_success_message(SuccessMessages.OVERLAY_DISCARDED.format(target_disk=target_disk))
1166
1176
  self.app.vm_service.invalidate_vm_state_cache(self.internal_id)
1167
1177
  self._boot_device_checked = False
1168
1178
  self.post_message(VmCardUpdateRequest(self.internal_id))
1169
1179
  self.update_button_layout()
1170
1180
  except Exception as e:
1171
- self.app.show_error_message(f"Error discarding overlay: {e}")
1181
+ self.app.show_error_message(ErrorMessages.ERROR_DISCARDING_OVERLAY_TEMPLATE.format(error=e))
1172
1182
  finally:
1173
1183
  self.app.vm_service.unsuppress_vm_events(self.internal_id)
1174
1184
 
@@ -1186,7 +1196,7 @@ class VMCard(Static):
1186
1196
  )
1187
1197
 
1188
1198
  except Exception as e:
1189
- self.app.show_error_message(f"Error preparing discard overlay: {e}")
1199
+ self.app.show_error_message(ErrorMessages.ERROR_PREPARING_DISCARD_OVERLAY_TEMPLATE.format(error=e))
1190
1200
 
1191
1201
 
1192
1202
  def _handle_commit_disk(self, event: Button.Pressed) -> None:
@@ -1202,7 +1212,7 @@ class VMCard(Static):
1202
1212
  break
1203
1213
 
1204
1214
  if not target_disk:
1205
- self.app.show_error_message("No disks found to commit.")
1215
+ self.app.show_error_message(ErrorMessages.NO_DISKS_FOUND_TO_COMMIT)
1206
1216
  return
1207
1217
 
1208
1218
  def on_confirm(confirmed: bool):
@@ -1214,11 +1224,11 @@ class VMCard(Static):
1214
1224
  self.app.vm_service.suppress_vm_events(self.internal_id)
1215
1225
  try:
1216
1226
  commit_disk_changes(self.vm, target_disk)
1217
- self.app.call_from_thread(self.app.show_success_message, "Disk changes committed successfully.")
1227
+ self.app.call_from_thread(self.app.show_success_message, SuccessMessages.DISK_COMMITTED)
1218
1228
  self.app.call_from_thread(self.app.refresh_vm_list)
1219
1229
  self.app.call_from_thread(self.update_button_layout)
1220
1230
  except Exception as e:
1221
- self.app.call_from_thread(self.app.show_error_message, f"Error committing disk: {e}")
1231
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.ERROR_COMMITTING_DISK_TEMPLATE.format(error=e))
1222
1232
  finally:
1223
1233
  self.app.vm_service.unsuppress_vm_events(self.internal_id)
1224
1234
  self.app.call_from_thread(progress_modal.dismiss)
@@ -1231,7 +1241,7 @@ class VMCard(Static):
1231
1241
  )
1232
1242
 
1233
1243
  except Exception as e:
1234
- self.app.show_error_message(f"Error preparing commit: {e}")
1244
+ self.app.show_error_message(ErrorMessages.ERROR_PREPARING_COMMIT_TEMPLATE.format(error=e))
1235
1245
 
1236
1246
  def _handle_shutdown_button(self, event: Button.Pressed) -> None:
1237
1247
  """Handles the shutdown button press."""
@@ -1239,6 +1249,27 @@ class VMCard(Static):
1239
1249
  if self.status in (StatusText.RUNNING, StatusText.PAUSED):
1240
1250
  self.post_message(VmActionRequest(self.internal_id, VmAction.STOP))
1241
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
+
1242
1273
  def stop_background_activities(self):
1243
1274
  """ Stop background activities before action """
1244
1275
  with self._timer_lock:
@@ -1273,7 +1304,7 @@ class VMCard(Static):
1273
1304
  self.stop_background_activities()
1274
1305
  self.post_message(VmActionRequest(self.internal_id, VmAction.PAUSE))
1275
1306
  else:
1276
- self.app.show_warning_message(f"VM '{self.name}' is not in a pausable state.")
1307
+ self.app.show_warning_message(WarningMessages.LIBVIRT_XML_NO_EFFECTIVE_CHANGE.format(vm_name=self.name))
1277
1308
 
1278
1309
  def _handle_resume_button(self, event: Button.Pressed) -> None:
1279
1310
  """Handles the resume button press."""
@@ -1304,42 +1335,41 @@ class VMCard(Static):
1304
1335
  try:
1305
1336
  conn = self.vm.connect()
1306
1337
  new_domain = conn.defineXML(modified_xml)
1307
-
1338
+
1308
1339
  # Verify if changes were effectively applied
1309
1340
  new_xml = new_domain.XMLDesc(xml_flags)
1310
-
1341
+
1311
1342
  if original_xml == new_xml:
1312
- self.app.show_warning_message(f"VM [b]{self.name}[/b]: Libvirt accepted the XML but the configuration remains unchanged. Your changes may have been ignored or normalized away.")
1343
+ self.app.show_warning_message(WarningMessages.LIBVIRT_XML_NO_EFFECTIVE_CHANGE.format(vm_name=self.name))
1313
1344
  logging.warning(f"XML update for {self.name} resulted in no effective changes.")
1314
1345
  else:
1315
- self.app.show_success_message(f"VM [b]{self.name}[/b] configuration updated successfully.")
1346
+ self.app.show_success_message(SuccessMessages.VM_CONFIG_UPDATED.format(vm_name=self.name))
1316
1347
  logging.info(f"Successfully updated XML for VM: {self.name}")
1317
-
1348
+
1318
1349
  self.app.vm_service.invalidate_vm_state_cache(self.internal_id)
1319
1350
  self._boot_device_checked = False
1320
1351
  self.app.refresh_vm_list()
1321
1352
  except libvirt.libvirtError as e:
1322
- error_msg = f"Invalid XML for '{self.name}': {e}. Your changes have been discarded."
1323
- self.app.show_error_message(error_msg)
1324
- logging.error(error_msg)
1353
+ self.app.show_error_message(ErrorMessages.INVALID_XML_TEMPLATE.format(vm_name=self.name, error=e))
1354
+ logging.error(e)
1325
1355
  else:
1326
- self.app.show_success_message("No changes made to the XML configuration.")
1356
+ self.app.show_success_message(SuccessMessages.NO_XML_CHANGES)
1327
1357
 
1328
1358
  self.app.push_screen(
1329
1359
  XMLDisplayModal(original_xml, read_only=not is_stopped),
1330
1360
  handle_xml_modal_result
1331
1361
  )
1332
1362
  except libvirt.libvirtError as e:
1333
- self.app.show_error_message(f"Error getting XML for VM [b]{self.name}[/b]: {e}")
1363
+ self.app.show_error_message(ErrorMessages.ERROR_GETTING_XML_TEMPLATE.format(vm_name=self.name, error=e))
1334
1364
  except Exception as e:
1335
- self.app.show_error_message(f"An unexpected error occurred: {e}")
1365
+ self.app.show_error_message(ErrorMessages.UNEXPECTED_ERROR_OCCURRED_TEMPLATE.format(error=e))
1336
1366
  logging.error(f"Unexpected error handling XML button: {traceback.format_exc()}")
1337
1367
 
1338
1368
  def _handle_connect_button(self, event: Button.Pressed) -> None:
1339
1369
  """Handles the connect button press by running the remove virt viewer in a worker."""
1340
1370
  logging.info(f"Attempting to connect to VM: {self.name}")
1341
1371
  if not hasattr(self, 'conn') or not self.conn:
1342
- self.app.show_error_message("Connection info not available for this VM.")
1372
+ self.app.show_error_message(ErrorMessages.CONNECTION_INFO_NOT_AVAILABLE)
1343
1373
  return
1344
1374
 
1345
1375
  def do_connect() -> None:
@@ -1368,7 +1398,7 @@ class VMCard(Static):
1368
1398
  logging.error(f"Failed to spawn {self.app.r_viewer} for {domain_name}: {e}")
1369
1399
  self.app.call_from_thread(
1370
1400
  self.app.show_error_message,
1371
- f"{self.app.r_viewer} failed to start for {domain_name}: {e}"
1401
+ ErrorMessages.REMOTE_VIEWER_FAILED_TO_START_TEMPLATE.format(viewer=self.app.r_viewer, domain_name=domain_name, error=e)
1372
1402
  )
1373
1403
  return
1374
1404
 
@@ -1380,13 +1410,13 @@ class VMCard(Static):
1380
1410
  except libvirt.libvirtError as e:
1381
1411
  self.app.call_from_thread(
1382
1412
  self.app.show_error_message,
1383
- f"Error getting VM details for [b]{self.name}[/b]: {e}"
1413
+ ErrorMessages.ERROR_GETTING_VM_DETAILS_TEMPLATE.format(vm_name=self.name, error=e)
1384
1414
  )
1385
1415
  except Exception as e:
1386
1416
  logging.error(f"An unexpected error occurred during connect: {e}", exc_info=True)
1387
1417
  self.app.call_from_thread(
1388
1418
  self.app.show_error_message,
1389
- "An unexpected error occurred while trying to connect."
1419
+ ErrorMessages.UNEXPECTED_ERROR_CONNECTING
1390
1420
  )
1391
1421
 
1392
1422
  self.app.worker_manager.run(do_connect, name=f"r_viewer_{self.name}")
@@ -1403,7 +1433,7 @@ class VMCard(Static):
1403
1433
  )
1404
1434
  return
1405
1435
  except Exception as e:
1406
- self.app.show_error_message(f"Error checking web console status for [b]{self.name}[/b]: {e}")
1436
+ self.app.show_error_message(ErrorMessages.ERROR_CHECKING_WEB_CONSOLE_STATUS_TEMPLATE.format(vm_name=self.name, error=e))
1407
1437
  return
1408
1438
 
1409
1439
  #is_remote = self.app.webconsole_manager.is_remote_connection(self.conn.getURI())
@@ -1427,6 +1457,50 @@ class VMCard(Static):
1427
1457
  else:
1428
1458
  self.app.worker_manager.run(worker, name=f"start_console_{self.vm.name()}")
1429
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
+
1430
1504
  def _handle_snapshot_take_button(self, event: Button.Pressed) -> None:
1431
1505
  """Handles the snapshot take button press."""
1432
1506
  logging.info(f"Attempting to take snapshot for VM: {self.name}")
@@ -1468,9 +1542,9 @@ class VMCard(Static):
1468
1542
  def finalize_snapshot():
1469
1543
  loading_modal.dismiss()
1470
1544
  if error:
1471
- self.app.show_error_message(f"Snapshot error for [b]{vm_name}[/b]: {error}")
1545
+ self.app.show_error_message(ErrorMessages.SNAPSHOT_ERROR_TEMPLATE.format(vm_name=vm_name, error=error))
1472
1546
  else:
1473
- self.app.show_success_message(f"Snapshot [b]{name}[/b] created successfully.")
1547
+ self.app.show_success_message(SuccessMessages.SNAPSHOT_CREATED.format(snapshot_name=name))
1474
1548
  # Defer refresh and restart stats to avoid racing
1475
1549
  self.app.set_timer(0.5, self._refresh_snapshot_tab_async)
1476
1550
  # Restart stats timer after a delay
@@ -1498,7 +1572,7 @@ class VMCard(Static):
1498
1572
  self.app.call_from_thread(loading_modal.dismiss)
1499
1573
 
1500
1574
  if not snapshots_info:
1501
- self.app.call_from_thread(self.app.show_error_message, "No snapshots to restore.")
1575
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.NO_SNAPSHOTS_TO_RESTORE)
1502
1576
  return
1503
1577
 
1504
1578
  def restore_snapshot_callback(snapshot_name: str | None) -> None:
@@ -1537,10 +1611,10 @@ class VMCard(Static):
1537
1611
  def finalize_ui():
1538
1612
  restore_loading_modal.dismiss()
1539
1613
  if error:
1540
- self.app.show_error_message(f"Error on VM [b]{vm_name}[/b] during 'snapshot restore': {error}")
1614
+ self.app.show_error_message(ErrorMessages.ERROR_ON_VM_DURING_ACTION.format(vm_name=vm_name, action='snapshot restore', error=error))
1541
1615
  else:
1542
1616
  self._boot_device_checked = False
1543
- self.app.show_success_message(f"Restored to snapshot [b]{snapshot_name}[/b] successfully.")
1617
+ self.app.show_success_message(SuccessMessages.SNAPSHOT_RESTORED.format(snapshot_name=snapshot_name))
1544
1618
  logging.info(f"Successfully restored snapshot [b]{snapshot_name}[/b] for VM: {vm_name}")
1545
1619
  self.app.refresh_vm_list(force=True)
1546
1620
  self.app.call_from_thread(finalize_ui)
@@ -1550,11 +1624,11 @@ class VMCard(Static):
1550
1624
  self.app.push_screen,
1551
1625
  SelectSnapshotDialog(snapshots_info, "Select snapshot to restore"),
1552
1626
  restore_snapshot_callback
1553
- )
1627
+ )
1554
1628
 
1555
1629
  except Exception as e:
1556
1630
  self.app.call_from_thread(loading_modal.dismiss)
1557
- self.app.call_from_thread(self.app.show_error_message, f"Error fetching snapshots: {e}")
1631
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.ERROR_FETCHING_SNAPSHOTS_TEMPLATE.format(error=e))
1558
1632
 
1559
1633
  self.app.worker_manager.run(fetch_and_show_worker, name=f"snapshot_restore_fetch_{self.internal_id}")
1560
1634
 
@@ -1575,7 +1649,7 @@ class VMCard(Static):
1575
1649
  self.app.call_from_thread(loading_modal.dismiss)
1576
1650
 
1577
1651
  if not snapshots_info:
1578
- self.app.call_from_thread(self.app.show_error_message, "No snapshots to delete.")
1652
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.NO_SNAPSHOTS_TO_DELETE)
1579
1653
  return
1580
1654
 
1581
1655
  def delete_snapshot_callback(snapshot_name: str | None) -> None:
@@ -1607,9 +1681,9 @@ class VMCard(Static):
1607
1681
  def finalize_ui():
1608
1682
  loading_modal.dismiss()
1609
1683
  if error:
1610
- self.app.show_error_message(f"Error on VM [b]{vm_name}[/b] during 'snapshot delete': {error}")
1684
+ self.app.show_error_message(ErrorMessages.ERROR_ON_VM_DURING_ACTION.format(vm_name=vm_name, action='snapshot delete', error=error))
1611
1685
  else:
1612
- self.app.show_success_message(f"Snapshot [b]{snapshot_name}[/b] deleted successfully.")
1686
+ self.app.show_success_message(SuccessMessages.SNAPSHOT_DELETED.format(snapshot_name=snapshot_name))
1613
1687
  logging.info(f"Successfully deleted snapshot '{snapshot_name}' for VM: {vm_name}")
1614
1688
  self.app.set_timer(0.1, self._refresh_snapshot_tab_async)
1615
1689
  # Restart stats timer
@@ -1631,7 +1705,7 @@ class VMCard(Static):
1631
1705
 
1632
1706
  except Exception as e:
1633
1707
  self.app.call_from_thread(loading_modal.dismiss)
1634
- self.app.call_from_thread(self.app.show_error_message, f"Error fetching snapshots: {e}")
1708
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.ERROR_FETCHING_SNAPSHOTS_TEMPLATE.format(error=e))
1635
1709
 
1636
1710
  self.app.worker_manager.run(fetch_and_show_worker, name=f"snapshot_delete_fetch_{self.internal_id}")
1637
1711
 
@@ -1685,12 +1759,12 @@ class VMCard(Static):
1685
1759
  pass
1686
1760
 
1687
1761
  # Also update button visibility
1688
- if self.ui.get(ButtonIds.RENAME_BUTTON):
1762
+ if self.ui.get("rename-button"):
1689
1763
  has_snapshots = snapshot_count > 0
1690
1764
  is_running = self.status == StatusText.RUNNING
1691
1765
  is_loading = self.status == StatusText.LOADING
1692
- self.ui[ButtonIds.SNAPSHOT_RESTORE].display = has_snapshots and not is_running and not is_loading
1693
- self.ui[ButtonIds.SNAPSHOT_DELETE].display = has_snapshots
1766
+ self.ui["snapshot_restore"].display = has_snapshots and not is_running and not is_loading
1767
+ self.ui["snapshot_delete"].display = has_snapshots
1694
1768
 
1695
1769
  self.app.call_from_thread(update_ui)
1696
1770
 
@@ -1744,7 +1818,7 @@ class VMCard(Static):
1744
1818
  # delete_vm handles opening its own connection
1745
1819
  delete_vm(self.vm, delete_storage=delete_storage, delete_nvram=True, log_callback=log_callback)
1746
1820
 
1747
- self.app.call_from_thread(self.app.show_success_message, f"VM '{vm_name}' deleted successfully.")
1821
+ self.app.call_from_thread(self.app.show_success_message, SuccessMessages.VM_DELETED.format(vm_name=vm_name))
1748
1822
 
1749
1823
  # Invalidate cache
1750
1824
  self.app.vm_service.invalidate_vm_cache(internal_id)
@@ -1757,7 +1831,7 @@ class VMCard(Static):
1757
1831
 
1758
1832
  except Exception as e:
1759
1833
  self.app.vm_service.unsuppress_vm_events(internal_id)
1760
- self.app.call_from_thread(self.app.show_error_message, f"Error deleting VM '{vm_name}': {e}")
1834
+ self.app.call_from_thread(self.app.show_error_message, ErrorMessages.ERROR_DELETING_VM_TEMPLATE.format(vm_name=vm_name, error=e))
1761
1835
  finally:
1762
1836
  self.app.call_from_thread(progress_modal.dismiss)
1763
1837
 
@@ -1812,8 +1886,8 @@ class VMCard(Static):
1812
1886
  existing_vm_names.add(name)
1813
1887
 
1814
1888
  except libvirt.libvirtError as e:
1815
- log_callback(f"ERROR: Error getting existing VM names: {e}")
1816
- app.call_from_thread(app.show_error_message, f"Error getting existing VM names: {e}")
1889
+ log_callback(f"ERROR: {ErrorMessages.ERROR_GETTING_EXISTING_VM_NAMES_TEMPLATE.format(error=e)}")
1890
+ app.call_from_thread(app.show_error_message, ErrorMessages.ERROR_GETTING_EXISTING_VM_NAMES_TEMPLATE.format(error=e))
1817
1891
  app.call_from_thread(progress_modal.dismiss)
1818
1892
  return
1819
1893
 
@@ -1852,11 +1926,11 @@ class VMCard(Static):
1852
1926
  app.call_from_thread(lambda: progress_modal.query_one("#progress-bar").advance(1))
1853
1927
 
1854
1928
  if success_clones:
1855
- msg = f"Successfully cloned to: {', '.join(success_clones)}"
1929
+ msg = SuccessMessages.VM_CLONED.format(vm_names=', '.join(success_clones))
1856
1930
  app.call_from_thread(app.show_success_message, msg)
1857
1931
  log_callback(msg)
1858
1932
  if failed_clones:
1859
- msg = f"Failed to clone to: {', '.join(failed_clones)}"
1933
+ msg = ErrorMessages.VM_CLONE_FAILED_TEMPLATE.format(vm_names=', '.join(failed_clones))
1860
1934
  app.call_from_thread(app.show_error_message, msg)
1861
1935
  log_callback(f"ERROR: {msg}")
1862
1936
 
@@ -1895,13 +1969,13 @@ class VMCard(Static):
1895
1969
  return
1896
1970
 
1897
1971
  if was_modified:
1898
- self.app.show_success_message(f"Input sanitized: [b]{new_name_raw}[/b] changed to [b]{new_name}[/b]")
1972
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=new_name_raw, sanitized_input=new_name))
1899
1973
 
1900
1974
  if not new_name:
1901
- self.app.show_error_message("VM name cannot be empty after sanitization.")
1975
+ self.app.show_error_message(ErrorMessages.VM_NAME_EMPTY_AFTER_SANITIZATION)
1902
1976
  return
1903
1977
  if new_name == self.name:
1904
- self.app.show_success_message("New VM name is the same as the old name. No rename performed.")
1978
+ self.app.show_success_message(SuccessMessages.VM_RENAME_NO_CHANGE)
1905
1979
  return
1906
1980
 
1907
1981
  def do_rename():
@@ -1909,26 +1983,23 @@ class VMCard(Static):
1909
1983
  self.app.vm_service.suppress_vm_events(internal_id)
1910
1984
  try:
1911
1985
  rename_vm(self.vm, new_name)
1912
- msg = f"VM '{self.name}' renamed to '{new_name}' successfully."
1913
- self.app.show_success_message(msg)
1986
+ self.app.show_success_message(SuccessMessages.VM_RENAMED.format(old_name=self.name, new_name=new_name))
1914
1987
  self.app.vm_service.invalidate_domain_cache()
1915
1988
  self._boot_device_checked = False
1916
1989
  self.app.refresh_vm_list()
1917
1990
  logging.info(f"Successfully renamed VM '{self.name}' to '{new_name}'")
1918
1991
  except Exception as e:
1919
- self.app.show_error_message(f"Error renaming VM [b]{self.name}[/b]: {e}")
1992
+ self.app.show_error_message(ErrorMessages.ERROR_RENAMING_VM_TEMPLATE.format(vm_name=self.name, error=e))
1920
1993
  finally:
1921
1994
  self.app.vm_service.unsuppress_vm_events(internal_id)
1922
1995
 
1923
- num_snapshots = self.vm.snapshotNum(0)
1924
-
1925
1996
  def on_confirm_rename(confirmed: bool, delete_snapshots=False) -> None:
1926
1997
  if confirmed:
1927
1998
  do_rename()
1928
1999
  if delete_snapshots:
1929
2000
  self.app.set_timer(0.1, self._refresh_snapshot_tab_async)
1930
2001
  else:
1931
- self.app.show_success_message("VM rename cancelled.")
2002
+ self.app.show_success_message(SuccessMessages.VM_RENAME_CANCELLED)
1932
2003
 
1933
2004
  msg = f"Are you sure you want to rename VM {self.name} to {new_name}?\n\nWarning: This operation involves undefining and redefining the VM."
1934
2005
  self.app.push_screen(
@@ -1958,70 +2029,64 @@ class VMCard(Static):
1958
2029
  def get_details_worker():
1959
2030
  try:
1960
2031
  result = self.app.vm_service.get_vm_details(
1961
- active_uris,
1962
- uuid,
1963
- domain=vm_obj,
1964
- conn=conn_obj,
2032
+ active_uris,
2033
+ uuid,
2034
+ domain=vm_obj,
2035
+ conn=conn_obj,
1965
2036
  cached_ips=cached_ips
1966
2037
  )
1967
2038
 
1968
2039
  def show_details():
1969
2040
  loading_modal.dismiss()
1970
2041
  if not result:
1971
- self.app.show_error_message(f"VM [b]{vm_name}[/b] with internal ID [b]{uuid}[/b] not found on any active server.")
2042
+ self.app.show_error_message(ErrorMessages.VM_NOT_FOUND_ON_ACTIVE_SERVER_TEMPLATE.format(vm_name=vm_name, uuid=uuid))
1972
2043
  return
1973
2044
 
1974
2045
  vm_info, domain, conn_for_domain = result
1975
2046
 
1976
- def on_detail_modal_dismissed(res):
2047
+ def on_detail_modal_dismissed(_=None):
1977
2048
  self.post_message(VmCardUpdateRequest(self.internal_id))
1978
2049
  self._perform_tooltip_update()
1979
2050
 
1980
2051
  self.app.push_screen(
1981
- 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),
1982
2058
  on_detail_modal_dismissed
1983
- )
2059
+ )
1984
2060
 
1985
2061
  self.app.call_from_thread(show_details)
1986
2062
 
1987
2063
  except Exception as e:
1988
- def show_error():
2064
+ def show_error(error_instance):
1989
2065
  loading_modal.dismiss()
1990
- self.app.show_error_message(f"Error getting details for [b]{vm_name}[/b]: {e}")
1991
- 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)
1992
2068
 
1993
2069
  self.app.worker_manager.run(get_details_worker, name=f"get_details_{uuid}")
1994
2070
 
1995
2071
  except Exception as e:
1996
- self.app.show_error_message(f"Error getting ID for [b]{self.name}[/b]: {e}")
2072
+ self.app.show_error_message(ErrorMessages.ERROR_GETTING_ID_TEMPLATE.format(vm_name=self.name, error=e))
1997
2073
 
1998
2074
  def _handle_migration_button(self, event: Button.Pressed) -> None:
1999
2075
  """Handles the migration button press."""
2000
2076
  if len(self.app.active_uris) < 2:
2001
- self.app.show_error_message("Please select at least two servers in 'Select Servers' to enable migration.")
2077
+ self.app.show_error_message(ErrorMessages.SELECT_AT_LEAST_TWO_SERVERS_FOR_MIGRATION)
2002
2078
  return
2003
2079
 
2004
- selected_vm_uuids = self.app.selected_vm_uuids
2080
+ selected_vm_uuids = list(self.app.selected_vm_uuids)
2005
2081
  selected_vms = []
2006
2082
  if selected_vm_uuids:
2083
+ found_domains_dict = self.app.vm_service.find_domains_by_uuids(self.app.active_uris, selected_vm_uuids)
2007
2084
  for uuid in selected_vm_uuids:
2008
- #Use cached domain lookup instead of iterating all URIs
2009
- with self.app.vm_service._cache_lock:
2010
- domain = self.app.vm_service._domain_cache.get(uuid)
2011
-
2085
+ domain = found_domains_dict.get(uuid)
2012
2086
  if domain:
2013
- try:
2014
- # Verify domain is still valid
2015
- domain.info()
2016
- selected_vms.append(domain)
2017
- found_domain = True
2018
- except libvirt.libvirtError:
2019
- found_domain = False
2087
+ selected_vms.append(domain)
2020
2088
  else:
2021
- found_domain = False
2022
- if not found_domain:
2023
- self.app.show_error_message(f"Selected VM with ID [b]{uuid}[/b] not found on any active server.")
2024
-
2089
+ self.app.show_error_message(ErrorMessages.SELECTED_VM_NOT_FOUND_ON_ACTIVE_SERVER_TEMPLATE.format(uuid=uuid))
2025
2090
  if not selected_vms:
2026
2091
  selected_vms = [self.vm]
2027
2092
 
@@ -2037,7 +2102,7 @@ class VMCard(Static):
2037
2102
  source_conns.add(uri)
2038
2103
 
2039
2104
  if len(source_conns) > 1:
2040
- self.app.show_error_message("Cannot migrate VMs from different source hosts at the same time.")
2105
+ self.app.show_error_message(ErrorMessages.DIFFERENT_SOURCE_HOSTS)
2041
2106
  return
2042
2107
 
2043
2108
  #active_vms = [vm for vm in selected_vms if vm.isActive()]
@@ -2051,14 +2116,14 @@ class VMCard(Static):
2051
2116
  state, _ = state_tuple
2052
2117
  if state in [libvirt.VIR_DOMAIN_RUNNING, libvirt.VIR_DOMAIN_PAUSED]:
2053
2118
  active_vms.append(vm)
2054
- except:
2119
+ except Exception:
2055
2120
  # Fallback to isActive() if cache lookup fails
2056
2121
  if vm.isActive():
2057
2122
  active_vms.append(vm)
2058
2123
 
2059
2124
  is_live = len(active_vms) > 0
2060
2125
  if is_live and len(active_vms) < len(selected_vms):
2061
- self.app.show_error_message("Cannot migrate running/paused and stopped VMs at the same time.")
2126
+ self.app.show_error_message(ErrorMessages.MIXED_VM_STATES)
2062
2127
  return
2063
2128
 
2064
2129
  active_uris = self.app.vm_service.get_all_uris()
@@ -2116,7 +2181,7 @@ class VMCard(Static):
2116
2181
  try:
2117
2182
  # Use vm_service to get XML (handles caching)
2118
2183
  self.app.vm_service._get_domain_xml(self.vm, internal_id=self.internal_id)
2119
-
2184
+
2120
2185
  # Update tooltip on main thread
2121
2186
  self.app.call_from_thread(self._perform_tooltip_update)
2122
2187
  self.app.call_from_thread(self.app.show_quick_message, f"Info refreshed for {self.name}")