virtui-manager 1.4.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.
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.
vmanager/vmanager.py CHANGED
@@ -62,7 +62,7 @@ from .utils import (
62
62
  generate_webconsole_keys_if_needed,
63
63
  get_server_color_cached,
64
64
  setup_cache_monitoring,
65
- setup_logging
65
+ setup_logging, is_running_under_flatpak
66
66
  )
67
67
  from .vm_queries import (
68
68
  get_status,
@@ -1291,7 +1291,7 @@ class VMManagerTUI(App):
1291
1291
 
1292
1292
  if result:
1293
1293
  vm_info, domain, conn = result
1294
- 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
1295
1295
 
1296
1296
  self.push_screen(
1297
1297
  VMDetailModal(
@@ -1326,7 +1326,7 @@ class VMManagerTUI(App):
1326
1326
 
1327
1327
  def _perform_bulk_action_worker(self, action_type: str, vm_uuids: list[str], delete_storage_flag: bool = False) -> None:
1328
1328
  """Worker function to orchestrate a bulk action using the VMService."""
1329
-
1329
+ bulk_failed = False
1330
1330
  # Stop workers for all selected VMs to prevent conflicts
1331
1331
  for uuid in vm_uuids:
1332
1332
  vm_card = self.vm_card_pool.active_cards.get(uuid)
@@ -1357,8 +1357,10 @@ class VMManagerTUI(App):
1357
1357
  logging.info(summary)
1358
1358
 
1359
1359
  if successful_vms:
1360
+ bulk_failed = False
1360
1361
  self.call_from_thread(self.show_success_message, SuccessMessages.BULK_ACTION_SUCCESS_TEMPLATE.format(action_type=action_type, count=len(successful_vms)))
1361
1362
  if failed_vms:
1363
+ bulk_failed = True
1362
1364
  self.call_from_thread(self.show_error_message, ErrorMessages.BULK_ACTION_FAILED_TEMPLATE.format(action_type=action_type, count=len(failed_vms)))
1363
1365
 
1364
1366
  except Exception as e:
@@ -1371,7 +1373,8 @@ class VMManagerTUI(App):
1371
1373
  # Unlock immediately so UI is not stuck if refresh fails
1372
1374
  def unlock_and_refresh():
1373
1375
  self.bulk_operation_in_progress = False
1374
- self.refresh_vm_list(force=True)
1376
+ if bulk_failed is False:
1377
+ self.refresh_vm_list(force=True)
1375
1378
 
1376
1379
  self.call_from_thread(unlock_and_refresh)
1377
1380
 
@@ -1386,6 +1389,13 @@ class VMManagerTUI(App):
1386
1389
 
1387
1390
  def refresh_vm_list(self, force: bool = False, optimize_for_current_page: bool = False, on_complete: Callable | None = None) -> None:
1388
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
+
1389
1399
  # Don't display VMs until initial cache is complete
1390
1400
  if self.initial_cache_loading and not self.initial_cache_complete:
1391
1401
  return
@@ -1493,13 +1503,15 @@ class VMManagerTUI(App):
1493
1503
  cpu = 0
1494
1504
  memory = 0
1495
1505
 
1506
+ simple_uuid = uuid.split('@')[0] if '@' in uuid else uuid
1507
+
1496
1508
  return {
1497
1509
  'uuid': uuid,
1498
1510
  'name': vm_name,
1499
1511
  'status': status,
1500
1512
  'cpu': cpu,
1501
1513
  'memory': memory,
1502
- 'is_selected': uuid in selected_uuids,
1514
+ 'is_selected': simple_uuid in selected_uuids,
1503
1515
  'domain': domain,
1504
1516
  'conn': conn,
1505
1517
  'uri': uri
@@ -1597,12 +1609,14 @@ class VMManagerTUI(App):
1597
1609
  card.name = data['name']
1598
1610
  card.cpu = data['cpu']
1599
1611
  card.memory = data['memory']
1600
- card.is_selected = data['is_selected']
1612
+
1601
1613
  card.server_border_color = self.get_server_color(data['uri'])
1602
1614
  card.status = data['status']
1603
1615
  card.internal_id = uuid
1604
1616
  card.compact_view = self.compact_view
1605
1617
 
1618
+ card.is_selected = data['is_selected']
1619
+
1606
1620
  # Mount any new cards
1607
1621
  if cards_to_mount:
1608
1622
  vms_container.mount(*cards_to_mount)
@@ -1910,12 +1924,22 @@ class VMManagerTUI(App):
1910
1924
 
1911
1925
  def main():
1912
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
+
1913
1937
  parser = argparse.ArgumentParser(description="A Textual application to manage VMs.")
1914
1938
  parser.add_argument("--cmd", action="store_true", help="Run in command-line interpreter mode.")
1915
1939
  args = parser.parse_args()
1916
1940
 
1917
1941
  if args.cmd:
1918
- from vmanager_cmd import VManagerCMD
1942
+ from .vmanager_cmd import VManagerCMD
1919
1943
  VManagerCMD().cmdloop()
1920
1944
  else:
1921
1945
  terminal_size = os.get_terminal_size()
vmanager/vmcard.css CHANGED
@@ -158,3 +158,19 @@ Button {
158
158
  margin: 0 0;
159
159
  padding: 0 0;
160
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
+ }
vmanager/vmcard.py CHANGED
@@ -56,7 +56,7 @@ from .utils import (
56
56
  from .constants import (
57
57
  ButtonLabels, TabTitles, StatusText,
58
58
  SparklineLabels, ErrorMessages, DialogMessages, VmAction,
59
- WarningMessages, SuccessMessages
59
+ WarningMessages, SuccessMessages, StaticText, ProgressMessages
60
60
  )
61
61
 
62
62
  class VMCardActions(Static):
@@ -252,6 +252,15 @@ class VMCard(Static):
252
252
  status_widget.update(status_text)
253
253
 
254
254
  def compose(self):
255
+ self.ui["btn_quick_start"] = Button("▶", id="start", variant="success", classes="btn-small")
256
+ self.ui["btn_quick_start"].tooltip = StaticText.START_VMS
257
+ self.ui["btn_quick_view"] = Button("👁", id="connect", classes="btn-small")
258
+ self.ui["btn_quick_view"].tooltip = StaticText.REMOTE_VIEWER
259
+ self.ui["btn_quick_resume"] = Button("⏯", id="resume", variant="success", classes="btn-small")
260
+ self.ui["btn_quick_resume"].tooltip = ButtonLabels.RESUME
261
+ self.ui["btn_quick_stop"] = Button("■", id="shutdown", variant="primary", classes="btn-small")
262
+ self.ui["btn_quick_stop"].tooltip = ButtonLabels.SHUTDOWN
263
+
255
264
  self.ui["checkbox"] = Checkbox("", id="vm-select-checkbox", classes="vm-select-checkbox", value=self.is_selected, tooltip="Select VM")
256
265
  self.ui["vmname"] = Static(self._get_vm_display_name(), id="vmname", classes="vmname")
257
266
  self.ui["status"] = Static(f"{self.status}{self.webc_status_indicator}", id="status")
@@ -290,6 +299,12 @@ class VMCard(Static):
290
299
  with Vertical():
291
300
  yield self.ui["vmname"]
292
301
  yield self.ui["status"]
302
+ with Horizontal(classes="quick-actions"):
303
+ with Vertical():
304
+ yield self.ui["btn_quick_resume"]
305
+ yield self.ui["btn_quick_stop"]
306
+ yield self.ui["btn_quick_view"]
307
+ yield self.ui["btn_quick_start"]
293
308
 
294
309
  yield self.ui["sparklines_container"]
295
310
  yield self.ui["collapsible"]
@@ -315,6 +330,8 @@ class VMCard(Static):
315
330
  # Clean up dynamic UI references to avoid memory leaks and stale state
316
331
  keys_to_keep = {
317
332
  "checkbox", "vmname", "status", "collapsible",
333
+ "btn_quick_start", "btn_quick_stop", "btn_quick_view",
334
+ "btn_quick_resume",
318
335
  "sparklines_container",
319
336
  # Resource Sparklines
320
337
  "cpu_label", "cpu_sparkline", "cpu_sparkline_container",
@@ -555,19 +572,20 @@ class VMCard(Static):
555
572
  if not self.ui:
556
573
  return
557
574
 
558
- #sparklines = self.ui.get("sparklines_container")
575
+ sparklines = self.ui.get("sparklines_container")
559
576
  collapsible = self.ui.get("collapsible")
560
577
  vmname = self.ui.get("vmname")
561
578
  vmstatus = self.ui.get("status")
562
579
  checkbox = self.ui.get("checkbox")
563
580
 
564
- if value: # if compact view, add hidden class
565
- #if sparklines and sparklines.is_mounted:
566
- # logging.info("DEBUG remove spark")
567
- # sparklines.remove()
581
+ if value:
582
+ if sparklines and sparklines.is_mounted:
583
+ sparklines.display = False
568
584
  if collapsible and collapsible.is_mounted:
569
585
  collapsible.collapsed = True
570
586
  collapsible.remove()
587
+ #if checkbox and checkbox.is_mounted:
588
+ # checkbox.display = False
571
589
  else:
572
590
  try:
573
591
  info_container = self.query_one("#info-container")
@@ -575,6 +593,11 @@ class VMCard(Static):
575
593
  # Check if collapsible is already a child of info_container to avoid double mounting
576
594
  if collapsible not in info_container.children:
577
595
  info_container.mount(collapsible)
596
+ if sparklines:
597
+ sparklines.display = True
598
+ #if checkbox:
599
+ # checkbox.display = True
600
+
578
601
  except NoMatches:
579
602
  # This can happen if the card is not fully initialized or structures changed
580
603
  logging.warning(f"Could not find #info-container on VMCard {self.name} when switching to detailed view.")
@@ -589,10 +612,11 @@ class VMCard(Static):
589
612
  if value: # Compact view
590
613
  self.styles.height = 4
591
614
  self.styles.width = 20
592
- if vmname: vmname.styles.content_align = ("left", "middle")
615
+ if vmname:
616
+ vmname.styles.content_align = ("left", "middle")
593
617
  if vmstatus: vmstatus.styles.content_align = ("left", "middle")
594
618
  if checkbox: checkbox.styles.width = "2"
595
- else: # Detailed view
619
+ else: # Detailed view
596
620
  self.styles.height = 14
597
621
  self.styles.width = 41
598
622
  if vmname: vmname.styles.content_align = ("center", "middle")
@@ -629,7 +653,17 @@ class VMCard(Static):
629
653
 
630
654
  def watch_server_border_color(self, old_color: str, new_color: str) -> None:
631
655
  """Called when server_border_color changes."""
632
- self.styles.border = ("solid", new_color)
656
+ if self.is_selected:
657
+ self.styles.border = ("panel", "white")
658
+ else:
659
+ self.styles.border = ("solid", new_color)
660
+
661
+ def on_click(self, event: Click) -> None:
662
+ """Handle click events on the card."""
663
+ if event.button == 3:
664
+ self.is_selected = not self.is_selected
665
+ self.post_message(VMSelectionChanged(vm_uuid=self.raw_uuid, is_selected=self.is_selected))
666
+ event.stop()
633
667
 
634
668
  def on_unmount(self) -> None:
635
669
  """Stop the timer and cancel any running stat workers when the widget is removed."""
@@ -673,11 +707,10 @@ class VMCard(Static):
673
707
 
674
708
  def watch_is_selected(self, old_value: bool, new_value: bool) -> None:
675
709
  """Called when is_selected changes to update the checkbox."""
676
- if not self.ui:
677
- return
678
- checkbox = self.ui.get("checkbox")
679
- if checkbox:
680
- checkbox.value = new_value
710
+ if self.ui:
711
+ checkbox = self.ui.get("checkbox")
712
+ if checkbox:
713
+ checkbox.value = new_value
681
714
 
682
715
  if new_value:
683
716
  self.styles.border = ("panel", "white")
@@ -888,6 +921,14 @@ class VMCard(Static):
888
921
  is_pmsuspended = self.status == StatusText.PMSUSPENDED
889
922
  is_blocked = self.status == StatusText.BLOCKED
890
923
 
924
+ if not self.ui.get("btn_quick_start"):
925
+ return
926
+
927
+ self.ui["btn_quick_start"].display = is_stopped
928
+ self.ui["btn_quick_stop"].display = is_running or is_blocked
929
+ self.ui["btn_quick_view"].display = (is_running or is_paused or is_blocked)
930
+ self.ui["btn_quick_resume"].display = is_paused or is_pmsuspended
931
+
891
932
  if not self.ui.get("rename-button"):
892
933
  return
893
934
 
@@ -1020,10 +1061,10 @@ class VMCard(Static):
1020
1061
  pane = tabbed_content.get_tab("snapshot-tab")
1021
1062
  if snapshot_count > 0:
1022
1063
  latest = snapshot_summary.get('latest')
1023
- info = f"Latest: {latest['name']} ({latest['time']})" if latest else "Unknown"
1064
+ info = f"{StaticText.LATEST_SNAPSHOT} {latest['name']} ({latest['time']})" if latest else "Unknown"
1024
1065
  pane.tooltip = f"{info}\nTotal: {snapshot_count}"
1025
1066
  else:
1026
- pane.tooltip = "No Snapshots created"
1067
+ pane.tooltip = StaticText.NO_SNAPSHOTS_CREATED
1027
1068
  except Exception:
1028
1069
  pass
1029
1070
 
@@ -1183,7 +1224,7 @@ class VMCard(Static):
1183
1224
  self.app.vm_service.unsuppress_vm_events(self.internal_id)
1184
1225
 
1185
1226
  self.app.push_screen(
1186
- ConfirmationDialog(f"Are you sure you want to discard changes in '{target_disk}' and revert to its backing file? This action cannot be undone."),
1227
+ ConfirmationDialog(DialogMessages.CONFIRM_DISCARD_CHANGES.format(target_disk=target_disk)),
1187
1228
  on_confirm
1188
1229
  )
1189
1230
 
@@ -1191,7 +1232,7 @@ class VMCard(Static):
1191
1232
  proceed_with_discard(overlay_disks[0])
1192
1233
  else:
1193
1234
  self.app.push_screen(
1194
- SelectDiskModal(overlay_disks, "Select overlay disk to discard:"),
1235
+ SelectDiskModal(overlay_disks, StaticText.SELECT_OVERLAY_DISCARD),
1195
1236
  proceed_with_discard
1196
1237
  )
1197
1238
 
@@ -1217,7 +1258,7 @@ class VMCard(Static):
1217
1258
 
1218
1259
  def on_confirm(confirmed: bool):
1219
1260
  if confirmed:
1220
- progress_modal = ProgressModal(title=f"Committing changes for {self.name}...")
1261
+ progress_modal = ProgressModal(title=ProgressMessages.COMMITTING_CHANGES_FOR.format(name=self.name))
1221
1262
  self.app.push_screen(progress_modal)
1222
1263
 
1223
1264
  def do_commit():
@@ -1236,7 +1277,7 @@ class VMCard(Static):
1236
1277
  self.app.worker_manager.run(do_commit, name=f"commit_{self.name}")
1237
1278
 
1238
1279
  self.app.push_screen(
1239
- ConfirmationDialog(f"Are you sure you want to merge changes from '{target_disk}' into its backing file?"),
1280
+ ConfirmationDialog(DialogMessages.CONFIRM_MERGE_CHANGES.format(target_disk=target_disk)),
1240
1281
  on_confirm
1241
1282
  )
1242
1283
 
@@ -1751,10 +1792,10 @@ class VMCard(Static):
1751
1792
  pane = tabbed_content.get_tab("snapshot-tab")
1752
1793
  if snapshot_count > 0:
1753
1794
  latest = snapshot_summary.get('latest')
1754
- info = f"Latest: {latest['name']} ({latest['time']})" if latest else "Unknown"
1795
+ info = f"{StaticText.LATEST_SNAPSHOT} {latest['name']} ({latest['time']})" if latest else "Unknown"
1755
1796
  pane.tooltip = f"{info}\nTotal: {snapshot_count}"
1756
1797
  else:
1757
- pane.tooltip = "No Snapshots created"
1798
+ pane.tooltip = StaticText.NO_SNAPSHOTS_CREATED
1758
1799
  except Exception:
1759
1800
  pass
1760
1801