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.
@@ -0,0 +1,199 @@
1
+ """
2
+ Host Stats modals
3
+ """
4
+ import logging
5
+ import threading
6
+ from textual.app import ComposeResult
7
+ from textual.widgets import Static, Label
8
+ from textual.reactive import reactive
9
+ from textual.events import Click, Message
10
+ from textual.worker import get_current_worker
11
+
12
+ from ..libvirt_utils import get_host_resources, get_active_vm_allocation
13
+ from ..utils import extract_server_name_from_uri
14
+
15
+ class SingleHostStat(Static):
16
+ """
17
+ Displays stats for a single host.
18
+ """
19
+ server_name = reactive("")
20
+
21
+ class ServerLabelClicked(Message):
22
+ """Posted when the server label is clicked."""
23
+ def __init__(self, server_uri: str, server_name: str) -> None:
24
+ super().__init__()
25
+ self.server_uri = server_uri
26
+ self.server_name = server_name
27
+
28
+ DEFAULT_CSS = """
29
+ SingleHostStat {
30
+ layout: horizontal;
31
+ height: auto;
32
+ padding: 0 1;
33
+ background: $boost;
34
+ align-vertical: middle;
35
+ }
36
+ .stat-label {
37
+ color: $text;
38
+ }
39
+ """
40
+
41
+ def __init__(self, uri: str, name: str, vm_service, server_color: str = "white"):
42
+ super().__init__()
43
+ self.uri = uri
44
+ self.server_name = name
45
+ self.vm_service = vm_service
46
+ self.server_color = server_color
47
+ self.server_label = Label("", id=f"single_host_stat_label_{self.server_name.replace(' ', '_').replace('.', '_')}")
48
+ self.cpu_label = Label("", classes="stat-label")
49
+ self.mem_label = Label("", classes="stat-label")
50
+ self.host_res = None
51
+
52
+ def compose(self) -> ComposeResult:
53
+ self.server_label.styles.color = self.server_color
54
+ self.server_label.styles.text_style = "bold"
55
+ self.server_label.update(f"{self.server_name} ")
56
+ yield self.server_label
57
+ yield self.cpu_label
58
+ yield Label(" ")
59
+ yield self.mem_label
60
+
61
+ def on_click(self, event: Click) -> None:
62
+ """Called when the user clicks on a widget."""
63
+ if event.control.id == self.server_label.id:
64
+ self.post_message(self.ServerLabelClicked(self.uri, self.server_name))
65
+
66
+ def update_stats(self):
67
+ """Fetches and updates stats for this host."""
68
+ def _fetch_and_update():
69
+ try:
70
+ # Check cancellation before potentially expensive op
71
+ try:
72
+ if get_current_worker().is_cancelled:
73
+ return
74
+ except Exception:
75
+ pass
76
+
77
+ conn = self.vm_service.connect(self.uri)
78
+ if not conn:
79
+ if threading.current_thread() is threading.main_thread():
80
+ self.cpu_label.update("Offline")
81
+ self.mem_label.update("Offline")
82
+ else:
83
+ self.app.call_from_thread(self.cpu_label.update, "Offline")
84
+ self.app.call_from_thread(self.mem_label.update, "Offline")
85
+ return
86
+
87
+ if self.host_res is None:
88
+ self.host_res = get_host_resources(conn)
89
+
90
+ current_alloc = get_active_vm_allocation(conn)
91
+
92
+ # Check cancellation again after expensive op
93
+ try:
94
+ if get_current_worker().is_cancelled:
95
+ return
96
+ except Exception:
97
+ pass
98
+
99
+ total_cpus = self.host_res.get('total_cpus', 1)
100
+ total_mem = self.host_res.get('available_memory', 1) # MB
101
+
102
+ used_cpus = current_alloc.get('active_allocated_vcpus', 0)
103
+ used_mem = current_alloc.get('active_allocated_memory', 0) # MB
104
+
105
+ cpu_pct = (used_cpus / total_cpus) * 100
106
+ mem_pct = (used_mem / total_mem) * 100
107
+ # Format memory string (GB if > 1024 MB)
108
+ def fmt_mem(mb):
109
+ if mb >= 1024:
110
+ return f"{mb/1024:.1f}G"
111
+ return f"{mb}M"
112
+
113
+ # UI Updates need to be on main thread
114
+ def _update_ui():
115
+ def get_status_bck(pct):
116
+ if pct >= 90:
117
+ return ("red")
118
+ if pct >= 75:
119
+ return ("orange")
120
+ if pct >= 55:
121
+ return ("yellow")
122
+ return ("green")
123
+
124
+ self.cpu_label.update(f"{used_cpus}/{total_cpus}CPU")
125
+ self.cpu_label.styles.background = get_status_bck(cpu_pct)
126
+
127
+ self.mem_label.update(f"{fmt_mem(used_mem)}/{fmt_mem(total_mem)}")
128
+ self.mem_label.styles.background = get_status_bck(mem_pct)
129
+
130
+ if threading.current_thread() is threading.main_thread():
131
+ _update_ui()
132
+ else:
133
+ self.app.call_from_thread(_update_ui)
134
+
135
+ except Exception as e:
136
+ logging.error(f"Error updating host stats for {self.name}: {e}")
137
+ if threading.current_thread() is threading.main_thread():
138
+ self.cpu_label.update("Err")
139
+ self.mem_label.update("Err")
140
+ else:
141
+ self.app.call_from_thread(self.cpu_label.update, "Err")
142
+ self.app.call_from_thread(self.mem_label.update, "Err")
143
+
144
+ _fetch_and_update()
145
+
146
+ class HostStats(Static):
147
+ """
148
+ Container for multiple SingleHostStat widgets.
149
+ """
150
+ DEFAULT_CSS = """
151
+ HostStats {
152
+ layout: grid;
153
+ grid-size: 3;
154
+ overflow-y: auto;
155
+ margin-bottom: 0;
156
+ margin-top: 0;
157
+ display: none;
158
+ }
159
+ """
160
+
161
+ def __init__(self, vm_service, get_server_color_callback):
162
+ super().__init__()
163
+ self.vm_service = vm_service
164
+ self.get_server_color = get_server_color_callback
165
+ self.active_hosts = {}
166
+
167
+ def update_hosts(self, active_uris, servers):
168
+ """
169
+ Reconciles the list of active hosts.
170
+ """
171
+ current_uris = set(active_uris)
172
+ existing_uris = set(self.active_hosts.keys())
173
+
174
+ # Remove stale
175
+ for uri in existing_uris - current_uris:
176
+ widget = self.active_hosts.pop(uri)
177
+ widget.remove()
178
+
179
+ # Add new hosts
180
+ for uri in current_uris - existing_uris:
181
+ name = self._get_server_name(uri, servers)
182
+ color = self.get_server_color(uri)
183
+ widget = SingleHostStat(uri, name, self.vm_service, color)
184
+ self.active_hosts[uri] = widget
185
+ self.mount(widget)
186
+ self.app.set_timer(0.5, widget.update_stats)
187
+
188
+ def _get_server_name(self, uri: str, servers) -> str:
189
+ """Helper to get server name from URI."""
190
+ if servers:
191
+ for s in servers:
192
+ if s['uri'] == uri:
193
+ return s.get('name', extract_server_name_from_uri(uri))
194
+ return extract_server_name_from_uri(uri)
195
+
196
+ def refresh_stats(self):
197
+ """Triggers update on all children."""
198
+ for widget in self.active_hosts.values():
199
+ widget.update_stats()
@@ -387,7 +387,7 @@ class MigrationModal(ModalScreen):
387
387
 
388
388
  write_log("\n[bold]--- Migration process finished ---[/]")
389
389
  self.app.call_from_thread(lambda: setattr(progress_bar.styles, "display", "none"))
390
- self.app.call_from_thread(self.app.refresh_vm_list)
390
+ self.app.call_from_thread(self.app.refresh_vm_list, force=True)
391
391
  self.app.call_from_thread(final_ui_state)
392
392
 
393
393
  @on(Checkbox.Changed, "#custom")
@@ -20,7 +20,7 @@ from textual.widgets import (
20
20
  )
21
21
 
22
22
  from .base_modals import BaseDialog, BaseModal
23
- from ..constants import ButtonLabels
23
+ from ..constants import ButtonLabels, StaticText
24
24
 
25
25
 
26
26
  def _sanitize_message(message: str) -> str:
vmanager/utils.py CHANGED
@@ -268,6 +268,20 @@ def check_websockify() -> bool:
268
268
  return False
269
269
 
270
270
 
271
+ def check_tmux() -> bool:
272
+ """
273
+ Checks if running inside a tmux session and tmux command is available.
274
+
275
+ Returns:
276
+ bool: True if inside tmux and tmux is installed, False otherwise
277
+ """
278
+ try:
279
+ return os.environ.get("TMUX") is not None and shutil.which("tmux") is not None
280
+ except Exception as e:
281
+ logging.error(f"Error checking tmux: {e}")
282
+ return False
283
+
284
+
271
285
  def check_is_firewalld_running() -> Union[str, bool]:
272
286
  """
273
287
  Check if firewalld is running.
@@ -464,7 +478,7 @@ def generate_tooltip_markdown(
464
478
  ) -> str:
465
479
  """Generate tooltip markdown (pure function, cacheable)."""
466
480
  mem_display = format_memory_display(memory)
467
- cpu_display = f"{cpu} ({cpu_model})" if cpu_model else str(cpu)
481
+ cpu_display = f"{cpu} {cpu_model}" if cpu_model else str(cpu)
468
482
 
469
483
  return (
470
484
  f"`{uuid}` \n"
vmanager/vm_actions.py CHANGED
@@ -1873,6 +1873,18 @@ def stop_vm(domain: libvirt.virDomain):
1873
1873
  invalidate_cache(get_internal_id(domain))
1874
1874
  domain.shutdown()
1875
1875
 
1876
+ def hibernate_vm(domain: libvirt.virDomain):
1877
+ """
1878
+ Saves (hibernates) the VM state to disk and stops it.
1879
+ """
1880
+ if not domain:
1881
+ raise ValueError("Invalid domain object.")
1882
+ if not domain.isActive():
1883
+ raise libvirt.libvirtError(f"VM '{domain.name()}' is not active, cannot save.")
1884
+
1885
+ invalidate_cache(get_internal_id(domain))
1886
+ domain.managedSave(0)
1887
+
1876
1888
  def pause_vm(domain: libvirt.virDomain):
1877
1889
  """
1878
1890
  Pauses the execution of the VM.
@@ -2231,6 +2243,14 @@ def check_vm_migration_compatibility(domain: libvirt.virDomain, dest_conn: libvi
2231
2243
  """
2232
2244
  issues = []
2233
2245
 
2246
+ # Check for name collision
2247
+ try:
2248
+ dest_conn.lookupByName(domain.name())
2249
+ issues.append({'severity': 'ERROR', 'message': f"A VM with the name '{domain.name()}' already exists on the destination host."})
2250
+ except libvirt.libvirtError as e:
2251
+ if e.get_error_code() != libvirt.VIR_ERR_NO_DOMAIN:
2252
+ issues.append({'severity': 'WARNING', 'message': f"Could not check for name collision on destination: {e}"})
2253
+
2234
2254
  try:
2235
2255
  xml_desc = domain.XMLDesc(0)
2236
2256
  root = ET.fromstring(xml_desc)
vmanager/vm_migration.py CHANGED
@@ -286,7 +286,10 @@ def custom_migrate_vm(source_conn: libvirt.virConnect, dest_conn: libvirt.virCon
286
286
  raise
287
287
 
288
288
  # 2. Analyze storage and propose move actions
289
- actions = []
289
+ actions = [{
290
+ "type": "vm_metadata",
291
+ "vm_name": domain.name()
292
+ }]
290
293
  root = ET.fromstring(xml_desc)
291
294
  disks = get_vm_disks_info(source_conn, root)
292
295
 
vmanager/vmanager.css CHANGED
@@ -512,7 +512,15 @@ Toast.-inprogress {
512
512
  .Buttonpage { height: auto; width: auto; margin: 0 0; padding: 1 1; color: white; background: black; border: round; }
513
513
  .Buttonpage:hover { background: $primary-darken-2; }
514
514
  .button-details { height: 1; padding: 0 0; }
515
- .ctrlpage { height: 3vh; width: auto; margin: 0 0; padding: 0 0; color: white; text-style: underline; background: $background; }
515
+ .ctrlpage {
516
+ height: 1;
517
+ width: auto;
518
+ margin: 0 0;
519
+ padding: 0 0;
520
+ color: white;
521
+ background: $background;
522
+ margin-bottom: 1;
523
+ }
516
524
  .close-button { height: 1; width: auto; color: white; background: red; border: hidden; margin: 1 1; }
517
525
  #delete-server-btn, #delete-button, .cancel-button { background: red; width: 25% }
518
526
  .rename-button, .edit-button, .add-button { background: green; }
vmanager/vmanager.py CHANGED
@@ -4,6 +4,7 @@ Main interface
4
4
  import os
5
5
  import sys
6
6
  import re
7
+ import threading
7
8
  from threading import RLock
8
9
  from concurrent.futures import ThreadPoolExecutor
9
10
  import logging
@@ -68,12 +69,13 @@ from .vm_queries import (
68
69
  )
69
70
  from .libvirt_utils import (
70
71
  get_internal_id, get_host_resources,
71
- get_total_vm_allocation, get_active_vm_allocation
72
+ get_active_vm_allocation
72
73
  )
73
74
  from .vm_service import VMService
74
75
  from .vmcard import VMCard
75
76
  from .vmcard_pool import VMCardPool
76
77
  from .webconsole_manager import WebConsoleManager
78
+ from .modals.host_stats import HostStats, SingleHostStat
77
79
 
78
80
  setup_logging()
79
81
 
@@ -251,6 +253,8 @@ class VMManagerTUI(App):
251
253
  self.last_increase = {} # Dict {uri: last_how_many_more}
252
254
  self.last_method_increase = {} # Dict {(uri, method): last_increase}
253
255
  self.r_viewer = None
256
+ self.host_stats = HostStats(self.vm_service, self.get_server_color)
257
+ self._hide_stats_timer = None
254
258
 
255
259
  def on_unmount(self) -> None:
256
260
  """Called when the application is unmounted."""
@@ -280,7 +284,7 @@ class VMManagerTUI(App):
280
284
  target = self.show_warning_message
281
285
  elif level == "progress":
282
286
  target = self.show_in_progress_message
283
-
287
+
284
288
  try:
285
289
  self.call_from_thread(target, message)
286
290
  except RuntimeError:
@@ -303,12 +307,36 @@ class VMManagerTUI(App):
303
307
  except RuntimeError:
304
308
  self.worker_manager._cleanup_finished_workers()
305
309
 
310
+ def _trigger_host_stats_refresh(self):
311
+ """Triggers a refresh of host statistics, cancelling any existing refresh."""
312
+ # Show host stats
313
+ self.host_stats.styles.display = "block"
314
+
315
+ # Reset hide timer
316
+ if self._hide_stats_timer:
317
+ self._hide_stats_timer.stop()
318
+ self._hide_stats_timer = self.set_timer(10.0, self._hide_host_stats)
319
+
320
+ self.worker_manager.cancel("host_stats_refresh")
321
+ self.worker_manager.run(
322
+ self.host_stats.refresh_stats,
323
+ name="host_stats_refresh",
324
+ description="Refreshing host stats"
325
+ )
326
+
327
+ def _hide_host_stats(self):
328
+ """Hides the host stats widget."""
329
+ self.host_stats.styles.display = "none"
330
+ self._hide_stats_timer = None
331
+
306
332
  def on_vm_update(self, internal_id: str):
307
333
  """Callback from VMService for specific VM updates."""
308
334
  try:
309
335
  self.call_from_thread(self.post_message, VmCardUpdateRequest(internal_id))
336
+ self.call_from_thread(self._trigger_host_stats_refresh)
310
337
  except RuntimeError:
311
338
  self.post_message(VmCardUpdateRequest(internal_id))
339
+ self._trigger_host_stats_refresh()
312
340
 
313
341
  def watch_bulk_operation_in_progress(self, in_progress: bool) -> None:
314
342
  """
@@ -341,7 +369,6 @@ class VMManagerTUI(App):
341
369
  def compose(self) -> ComposeResult:
342
370
  """Create child widgets for the app."""
343
371
  self.ui["vms_container"] = Vertical(id="vms-container")
344
- self.ui["error_footer"] = Static(id="error-footer", classes="error-message")
345
372
  self.ui["page_info"] = Label("", id="page-info", classes="")
346
373
  self.ui["prev_button"] = Button(
347
374
  ButtonLabels.PREVIOUS_PAGE, id="prev-button", variant="primary", classes="ctrlpage"
@@ -381,8 +408,8 @@ class VMManagerTUI(App):
381
408
  yield Link("About", url="https://aginies.github.io/virtui-manager/")
382
409
 
383
410
  yield self.ui["pagination_controls"]
411
+ yield self.host_stats
384
412
  yield self.ui["vms_container"]
385
- yield self.ui["error_footer"]
386
413
  yield Footer()
387
414
  self.show_success_message(SuccessMessages.TERMINAL_COPY_HINT)
388
415
 
@@ -551,7 +578,7 @@ class VMManagerTUI(App):
551
578
  self.call_from_thread(self.show_error_message, ErrorMessages.SERVER_CONNECTION_ERROR.format(server_name=server_name, error_msg=error_msg))
552
579
 
553
580
  if self.vm_service.connection_manager.is_max_retries_reached(uri):
554
- self.call_from_thread(self.remove_active_uri, uri)
581
+ self.call_from_thread(self.remove_active_uri, uri)
555
582
 
556
583
  # Pre-cache info and XML only for the first page of VMs
557
584
  # Full info will be loaded on-demand when cards are displayed
@@ -582,7 +609,7 @@ class VMManagerTUI(App):
582
609
 
583
610
  except Exception as e:
584
611
  self.call_from_thread(
585
- self.show_error_message,
612
+ self.show_error_message,
586
613
  ErrorMessages.ERROR_DURING_INITIAL_CACHE_LOADING.format(error=e)
587
614
  )
588
615
 
@@ -633,7 +660,7 @@ class VMManagerTUI(App):
633
660
  vms_container.styles.grid_size_columns = cols
634
661
 
635
662
  old_vms_per_page = self.VMS_PER_PAGE
636
-
663
+
637
664
  self.VMS_PER_PAGE = cols * rows
638
665
  if self.compact_view:
639
666
  self.VMS_PER_PAGE = cols * rows + cols
@@ -1155,6 +1182,13 @@ class VMManagerTUI(App):
1155
1182
  )
1156
1183
  finally:
1157
1184
  self.vm_service.unsuppress_vm_events(message.internal_id)
1185
+
1186
+ # Show stats update since event was suppressed
1187
+ try:
1188
+ self.call_from_thread(self._trigger_host_stats_refresh)
1189
+ except RuntimeError:
1190
+ pass
1191
+
1158
1192
  # Always try to unset the flag from the main thread
1159
1193
  def unset_flag():
1160
1194
  if self.bulk_operation_in_progress:
@@ -1320,7 +1354,7 @@ class VMManagerTUI(App):
1320
1354
  )
1321
1355
 
1322
1356
  summary = f"Bulk action '{action_type}' complete. Successful: {len(successful_vms)}, Failed: {len(failed_vms)}"
1323
- logging.info(summary)
1357
+ logging.info(summary)
1324
1358
 
1325
1359
  if successful_vms:
1326
1360
  self.call_from_thread(self.show_success_message, SuccessMessages.BULK_ACTION_SUCCESS_TEMPLATE.format(action_type=action_type, count=len(successful_vms)))
@@ -1391,6 +1425,12 @@ class VMManagerTUI(App):
1391
1425
  ):
1392
1426
  """Worker to fetch, filter, and display VMs using a diffing strategy."""
1393
1427
  try:
1428
+ # Update Host Stats
1429
+ if threading.current_thread() is threading.main_thread():
1430
+ self.host_stats.update_hosts(uris_to_query, self.servers)
1431
+ else:
1432
+ self.call_from_thread(self.host_stats.update_hosts, uris_to_query, self.servers)
1433
+
1394
1434
  start_index = current_page * vms_per_page
1395
1435
  end_index = start_index + vms_per_page
1396
1436
  page_start = start_index if optimize_for_current_page else None
@@ -1826,7 +1866,7 @@ class VMManagerTUI(App):
1826
1866
  else:
1827
1867
  cpu = 0
1828
1868
  memory = 0
1829
-
1869
+
1830
1870
  logging.debug(f"Updating card {vm_internal_id} with status {status}")
1831
1871
  # Update card on main thread
1832
1872
  def update_ui():
@@ -1846,6 +1886,27 @@ class VMManagerTUI(App):
1846
1886
  update_single_card, name=f"update_card_{vm_internal_id}"
1847
1887
  )
1848
1888
 
1889
+ @on(SingleHostStat.ServerLabelClicked)
1890
+ def on_single_host_stat_server_label_clicked(self, message: SingleHostStat.ServerLabelClicked) -> None:
1891
+ """Called when a server label is clicked in the host stats."""
1892
+ available_servers = []
1893
+ for uri in self.active_uris:
1894
+ name = uri
1895
+ for s in self.servers:
1896
+ if s['uri'] == uri:
1897
+ name = s['name']
1898
+ break
1899
+ available_servers.append({'name': name, 'uri': uri, 'color': self.get_server_color(uri)})
1900
+
1901
+ self.push_screen(
1902
+ FilterModal(
1903
+ current_search=self.search_text,
1904
+ current_status=self.sort_by,
1905
+ available_servers=available_servers,
1906
+ selected_servers=[message.server_uri] # Pre-select the clicked server
1907
+ )
1908
+ )
1909
+
1849
1910
 
1850
1911
  def main():
1851
1912
  """Entry point for vmanager TUI application."""
vmanager/vmcard.css CHANGED
@@ -61,7 +61,7 @@ VMCard {
61
61
 
62
62
  #status, Button, .Buttonpage, #xml, #snapshot_take,
63
63
  #snapshot_restore, #snapshot_delete, .button-separator,
64
- #connect, #create_overlay, #commit_disk, #discard_overlay {
64
+ #connect, #create_overlay, #commit_disk, #discard_overlay, #tmux_console {
65
65
  height: 1;
66
66
  padding: 0;
67
67
  }
@@ -134,6 +134,8 @@ Button {
134
134
  #pause:hover { background: #FFD700; }
135
135
  #connect { background: red; }
136
136
  #connect:hover { background: #FF4500; }
137
+ #tmux_console { background: #008080; }
138
+ #tmux_console:hover { background: #20B2AA; }
137
139
  #create_overlay { background: green; }
138
140
  #create_overlay:hover { background: lightgreen; }
139
141
  #commit_disk { background: #0000FF; }