virtui-manager 1.3.0__py3-none-any.whl → 1.5.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
@@ -13,6 +13,9 @@ from typing import List, Tuple, Union, Callable
13
13
  from urllib.parse import urlparse
14
14
  from .constants import AppInfo
15
15
 
16
+ def is_running_under_flatpak():
17
+ return 'FLATPAK_ID' in os.environ
18
+
16
19
  def find_free_port(start: int, end: int) -> int:
17
20
  """
18
21
  Find a free port in the specified range.
@@ -268,6 +271,20 @@ def check_websockify() -> bool:
268
271
  return False
269
272
 
270
273
 
274
+ def check_tmux() -> bool:
275
+ """
276
+ Checks if running inside a tmux session and tmux command is available.
277
+
278
+ Returns:
279
+ bool: True if inside tmux and tmux is installed, False otherwise
280
+ """
281
+ try:
282
+ return os.environ.get("TMUX") is not None and shutil.which("tmux") is not None
283
+ except Exception as e:
284
+ logging.error(f"Error checking tmux: {e}")
285
+ return False
286
+
287
+
271
288
  def check_is_firewalld_running() -> Union[str, bool]:
272
289
  """
273
290
  Check if firewalld is running.
@@ -464,7 +481,7 @@ def generate_tooltip_markdown(
464
481
  ) -> str:
465
482
  """Generate tooltip markdown (pure function, cacheable)."""
466
483
  mem_display = format_memory_display(memory)
467
- cpu_display = f"{cpu} ({cpu_model})" if cpu_model else str(cpu)
484
+ cpu_display = f"{cpu} {cpu_model}" if cpu_model else str(cpu)
468
485
 
469
486
  return (
470
487
  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
@@ -61,19 +62,20 @@ from .utils import (
61
62
  generate_webconsole_keys_if_needed,
62
63
  get_server_color_cached,
63
64
  setup_cache_monitoring,
64
- setup_logging
65
+ setup_logging, is_running_under_flatpak
65
66
  )
66
67
  from .vm_queries import (
67
68
  get_status,
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:
@@ -1257,7 +1291,7 @@ class VMManagerTUI(App):
1257
1291
 
1258
1292
  if result:
1259
1293
  vm_info, domain, conn = result
1260
- from modals.vmdetails_modals import VMDetailModal # Import here to avoid circular dep if any
1294
+ from .modals.vmdetails_modals import VMDetailModal # Import here to avoid circular dep if any
1261
1295
 
1262
1296
  self.push_screen(
1263
1297
  VMDetailModal(
@@ -1292,7 +1326,7 @@ class VMManagerTUI(App):
1292
1326
 
1293
1327
  def _perform_bulk_action_worker(self, action_type: str, vm_uuids: list[str], delete_storage_flag: bool = False) -> None:
1294
1328
  """Worker function to orchestrate a bulk action using the VMService."""
1295
-
1329
+ bulk_failed = False
1296
1330
  # Stop workers for all selected VMs to prevent conflicts
1297
1331
  for uuid in vm_uuids:
1298
1332
  vm_card = self.vm_card_pool.active_cards.get(uuid)
@@ -1320,11 +1354,13 @@ 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:
1360
+ bulk_failed = False
1326
1361
  self.call_from_thread(self.show_success_message, SuccessMessages.BULK_ACTION_SUCCESS_TEMPLATE.format(action_type=action_type, count=len(successful_vms)))
1327
1362
  if failed_vms:
1363
+ bulk_failed = True
1328
1364
  self.call_from_thread(self.show_error_message, ErrorMessages.BULK_ACTION_FAILED_TEMPLATE.format(action_type=action_type, count=len(failed_vms)))
1329
1365
 
1330
1366
  except Exception as e:
@@ -1337,7 +1373,8 @@ class VMManagerTUI(App):
1337
1373
  # Unlock immediately so UI is not stuck if refresh fails
1338
1374
  def unlock_and_refresh():
1339
1375
  self.bulk_operation_in_progress = False
1340
- self.refresh_vm_list(force=True)
1376
+ if bulk_failed is False:
1377
+ self.refresh_vm_list(force=True)
1341
1378
 
1342
1379
  self.call_from_thread(unlock_and_refresh)
1343
1380
 
@@ -1352,6 +1389,13 @@ class VMManagerTUI(App):
1352
1389
 
1353
1390
  def refresh_vm_list(self, force: bool = False, optimize_for_current_page: bool = False, on_complete: Callable | None = None) -> None:
1354
1391
  """Refreshes the list of VMs by running the fetch-and-display logic in a worker."""
1392
+ # Prevent refresh during bulk operations to maintain UI stability
1393
+ if self.bulk_operation_in_progress:
1394
+ logging.debug("Skipping refresh_vm_list because bulk operation is in progress.")
1395
+ if on_complete:
1396
+ on_complete()
1397
+ return
1398
+
1355
1399
  # Don't display VMs until initial cache is complete
1356
1400
  if self.initial_cache_loading and not self.initial_cache_complete:
1357
1401
  return
@@ -1391,6 +1435,12 @@ class VMManagerTUI(App):
1391
1435
  ):
1392
1436
  """Worker to fetch, filter, and display VMs using a diffing strategy."""
1393
1437
  try:
1438
+ # Update Host Stats
1439
+ if threading.current_thread() is threading.main_thread():
1440
+ self.host_stats.update_hosts(uris_to_query, self.servers)
1441
+ else:
1442
+ self.call_from_thread(self.host_stats.update_hosts, uris_to_query, self.servers)
1443
+
1394
1444
  start_index = current_page * vms_per_page
1395
1445
  end_index = start_index + vms_per_page
1396
1446
  page_start = start_index if optimize_for_current_page else None
@@ -1453,13 +1503,15 @@ class VMManagerTUI(App):
1453
1503
  cpu = 0
1454
1504
  memory = 0
1455
1505
 
1506
+ simple_uuid = uuid.split('@')[0] if '@' in uuid else uuid
1507
+
1456
1508
  return {
1457
1509
  'uuid': uuid,
1458
1510
  'name': vm_name,
1459
1511
  'status': status,
1460
1512
  'cpu': cpu,
1461
1513
  'memory': memory,
1462
- 'is_selected': uuid in selected_uuids,
1514
+ 'is_selected': simple_uuid in selected_uuids,
1463
1515
  'domain': domain,
1464
1516
  'conn': conn,
1465
1517
  'uri': uri
@@ -1557,12 +1609,14 @@ class VMManagerTUI(App):
1557
1609
  card.name = data['name']
1558
1610
  card.cpu = data['cpu']
1559
1611
  card.memory = data['memory']
1560
- card.is_selected = data['is_selected']
1612
+
1561
1613
  card.server_border_color = self.get_server_color(data['uri'])
1562
1614
  card.status = data['status']
1563
1615
  card.internal_id = uuid
1564
1616
  card.compact_view = self.compact_view
1565
1617
 
1618
+ card.is_selected = data['is_selected']
1619
+
1566
1620
  # Mount any new cards
1567
1621
  if cards_to_mount:
1568
1622
  vms_container.mount(*cards_to_mount)
@@ -1826,7 +1880,7 @@ class VMManagerTUI(App):
1826
1880
  else:
1827
1881
  cpu = 0
1828
1882
  memory = 0
1829
-
1883
+
1830
1884
  logging.debug(f"Updating card {vm_internal_id} with status {status}")
1831
1885
  # Update card on main thread
1832
1886
  def update_ui():
@@ -1846,15 +1900,46 @@ class VMManagerTUI(App):
1846
1900
  update_single_card, name=f"update_card_{vm_internal_id}"
1847
1901
  )
1848
1902
 
1903
+ @on(SingleHostStat.ServerLabelClicked)
1904
+ def on_single_host_stat_server_label_clicked(self, message: SingleHostStat.ServerLabelClicked) -> None:
1905
+ """Called when a server label is clicked in the host stats."""
1906
+ available_servers = []
1907
+ for uri in self.active_uris:
1908
+ name = uri
1909
+ for s in self.servers:
1910
+ if s['uri'] == uri:
1911
+ name = s['name']
1912
+ break
1913
+ available_servers.append({'name': name, 'uri': uri, 'color': self.get_server_color(uri)})
1914
+
1915
+ self.push_screen(
1916
+ FilterModal(
1917
+ current_search=self.search_text,
1918
+ current_status=self.sort_by,
1919
+ available_servers=available_servers,
1920
+ selected_servers=[message.server_uri] # Pre-select the clicked server
1921
+ )
1922
+ )
1923
+
1849
1924
 
1850
1925
  def main():
1851
1926
  """Entry point for vmanager TUI application."""
1927
+ if is_running_under_flatpak():
1928
+ ldir = "/app/share/locale"
1929
+ else:
1930
+ if not os.path.exists("locale"):
1931
+ # Installed on the system
1932
+ ldir = "/usr/share/locale"
1933
+ else:
1934
+ # Devel version from git
1935
+ ldir = "locale"
1936
+
1852
1937
  parser = argparse.ArgumentParser(description="A Textual application to manage VMs.")
1853
1938
  parser.add_argument("--cmd", action="store_true", help="Run in command-line interpreter mode.")
1854
1939
  args = parser.parse_args()
1855
1940
 
1856
1941
  if args.cmd:
1857
- from vmanager_cmd import VManagerCMD
1942
+ from .vmanager_cmd import VManagerCMD
1858
1943
  VManagerCMD().cmdloop()
1859
1944
  else:
1860
1945
  terminal_size = os.get_terminal_size()
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; }
@@ -156,3 +158,19 @@ Button {
156
158
  margin: 0 0;
157
159
  padding: 0 0;
158
160
  }
161
+
162
+ .name-box {
163
+ height: 2;
164
+ }
165
+
166
+ .quick-actions {
167
+ width: 8;
168
+ height: 2;
169
+ padding: 0 0;
170
+ }
171
+ .btn-small {
172
+ min-width: 2;
173
+ height: 1;
174
+ border: none;
175
+ padding: 0 0;
176
+ }