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
@@ -12,6 +12,7 @@ from textual.widgets import (
12
12
  from textual.app import ComposeResult
13
13
  from textual import on
14
14
  from ..storage_manager import create_storage_pool, list_storage_pools
15
+ from ..constants import ErrorMessages, SuccessMessages, ButtonLabels, StaticText
15
16
  from .base_modals import BaseModal, ValueListItem
16
17
  from .utils_modals import DirectorySelectionModal, FileSelectionModal
17
18
  from .input_modals import _sanitize_input
@@ -33,7 +34,7 @@ class SelectPoolModal(BaseModal[str | None]):
33
34
  *[ValueListItem(Label(pool), value=pool) for pool in self.pools],
34
35
  id="pool-selection-list"
35
36
  )
36
- yield Button("Cancel", variant="error", id="cancel")
37
+ yield Button(ButtonLabels.CANCEL, variant="error", id="cancel")
37
38
 
38
39
  def on_list_view_selected(self, event: ListView.Selected) -> None:
39
40
  self.selected_pool = event.item.value
@@ -61,7 +62,7 @@ class SelectDiskModal(BaseModal[str | None]):
61
62
  *[ValueListItem(Label(os.path.basename(disk)), value=disk) for disk in self.disks],
62
63
  id="disk-selection-list"
63
64
  )
64
- yield Button("Cancel", variant="error", id="cancel")
65
+ yield Button(ButtonLabels.CANCEL, variant="error", id="cancel")
65
66
 
66
67
  def on_list_view_selected(self, event: ListView.Selected) -> None:
67
68
  self.selected_disk = event.item.value
@@ -80,14 +81,14 @@ class RemoveDiskModal(BaseModal[str | None]):
80
81
 
81
82
  def compose(self) -> ComposeResult:
82
83
  with Vertical(id="remove-disk-dialog"):
83
- yield Label("Select Disk to Remove")
84
+ yield Label(StaticText.SELECT_DISK_TO_REMOVE)
84
85
  yield ListView(
85
86
  *[ValueListItem(Label(disk), value=disk) for disk in self.disks],
86
87
  id="remove-disk-list"
87
88
  )
88
89
  with Horizontal():
89
- yield Button("Remove", variant="error", id="remove-btn", classes="Buttonpage delete-button")
90
- yield Button("Cancel", variant="default", id="cancel-btn", classes="Buttonpage")
90
+ yield Button(ButtonLabels.REMOVE, variant="error", id="remove-btn", classes="Buttonpage delete-button")
91
+ yield Button(ButtonLabels.CANCEL, variant="default", id="cancel-btn", classes="Buttonpage")
91
92
 
92
93
  def on_list_view_selected(self, event: ListView.Selected) -> None:
93
94
  self.selected_disk = event.item.value
@@ -107,11 +108,11 @@ class AddDiskModal(BaseModal[dict | None]):
107
108
 
108
109
  def compose(self) -> ComposeResult:
109
110
  with ScrollableContainer(id="add-disk-dialog"):
110
- yield Label("Add New Disk")
111
+ yield Label(StaticText.ADD_NEW_DISK)
111
112
  with Horizontal():
112
113
  yield Input(placeholder="Path to existing disk image or ISO", id="disk-path-input")
113
- yield Button("Browse", id="browse-disk-btn")
114
- yield Checkbox("Create new disk image", id="create-disk-checkbox")
114
+ yield Button(ButtonLabels.BROWSE, id="browse-disk-btn")
115
+ yield Checkbox(StaticText.CREATE_NEW_DISK_IMAGE, id="create-disk-checkbox")
115
116
 
116
117
  # Fields for creating a new disk
117
118
  yield Select(
@@ -124,14 +125,14 @@ class AddDiskModal(BaseModal[dict | None]):
124
125
  yield Input(placeholder="Size in GB (e.g., 10)", id="disk-size-input", disabled=True)
125
126
  yield Select([("qcow2", "qcow2"), ("raw", "raw")], id="disk-format-select", disabled=True, value="qcow2")
126
127
 
127
- yield Checkbox("CD-ROM", id="cdrom-checkbox")
128
+ yield Checkbox(StaticText.CD_ROM, id="cdrom-checkbox")
128
129
  yield Select(
129
130
  [("virtio", "virtio"), ("sata", "sata"), ("scsi", "scsi"), ("ide", "ide"), ("usb", "usb")],
130
131
  id="disk-bus-select", value="virtio"
131
132
  )
132
133
  with Horizontal():
133
- yield Button("Add", variant="primary", id="add-btn", classes="Buttonpage")
134
- yield Button("Cancel", variant="default", id="cancel-btn", classes="Buttonpage")
134
+ yield Button(ButtonLabels.ADD, variant="primary", id="add-btn", classes="Buttonpage")
135
+ yield Button(ButtonLabels.CANCEL, variant="default", id="cancel-btn", classes="Buttonpage")
135
136
 
136
137
  def _update_device_type_from_path(self, path: str) -> None:
137
138
  """Automatically sets CD-ROM checkbox based on file extension."""
@@ -223,7 +224,7 @@ class AddDiskModal(BaseModal[dict | None]):
223
224
  disk_format = self.query_one("#disk-format-select", Select).value
224
225
 
225
226
  if not all([pool, vol_name, disk_size_str]):
226
- self.app.show_error_message("Pool, Volume Name, and Size are required to create a new disk.")
227
+ self.app.show_error_message(ErrorMessages.CREATE_DISK_REQUIRED_FIELDS)
227
228
  return
228
229
 
229
230
  numeric_part = re.sub(r'[^0-9]', '', disk_size_str)
@@ -238,7 +239,7 @@ class AddDiskModal(BaseModal[dict | None]):
238
239
  else:
239
240
  disk_path = self.query_one("#disk-path-input", Input).value
240
241
  if not disk_path:
241
- self.app.show_error_message("Path to disk image is required.")
242
+ self.app.show_error_message(ErrorMessages.DISK_IMAGE_PATH_REQUIRED)
242
243
  return
243
244
  result["disk_path"] = disk_path
244
245
 
@@ -255,7 +256,7 @@ class AddPoolModal(BaseModal[bool | None]):
255
256
 
256
257
  def compose(self) -> ComposeResult:
257
258
  with Vertical(id="add-pool-dialog"):
258
- yield Label("Add New Storage Pool")
259
+ yield Label(StaticText.ADD_NEW_STORAGE_POOL)
259
260
  yield Input(placeholder="Pool Name (e.g., my_pool)", id="pool-name-input")
260
261
  yield Select(
261
262
  [
@@ -269,32 +270,32 @@ class AddPoolModal(BaseModal[bool | None]):
269
270
 
270
271
  # Fields for `dir` type
271
272
  with Vertical(id="dir-fields"):
272
- yield Label("Target Path (for volumes)")
273
+ yield Label(StaticText.TARGET_PATH_VOLUMES)
273
274
  with Vertical():
274
275
  with Horizontal():
275
276
  yield Input(value="/var/lib/libvirt/images/", id="dir-target-path-input", placeholder="/var/lib/libvirt/images/>")
276
- yield Button("Browse", id="browse-dir-btn")
277
+ yield Button(ButtonLabels.BROWSE, id="browse-dir-btn")
277
278
 
278
279
  # Fields for `netfs` type
279
280
  with Vertical(id="netfs-fields"):
280
281
  with ScrollableContainer():
281
- yield Label("Target Path (on this host)")
282
+ yield Label(StaticText.TARGET_PATH_HOST)
282
283
  with Vertical():
283
284
  yield Input(placeholder="/mnt/nfs", id="netfs-target-path-input")
284
- yield Button("Browse", id="browse-netfs-btn")
285
+ yield Button(ButtonLabels.BROWSE, id="browse-netfs-btn")
285
286
  yield Select(
286
287
  [("auto", "auto"), ("nfs", "nfs"), ("glusterfs", "glusterfs"), ("cifs", "cifs")],
287
288
  id="netfs-format-select",
288
289
  value="auto"
289
290
  )
290
- yield Label("Source Hostname")
291
+ yield Label(StaticText.SOURCE_HOSTNAME)
291
292
  yield Input(placeholder="nfs.example.com", id="netfs-host-input")
292
- yield Label("Source Path (on remote host)")
293
+ yield Label(StaticText.SOURCE_PATH_REMOTE)
293
294
  yield Input(placeholder="host0", id="netfs-source-path-input", value="host0")
294
295
 
295
296
  with Horizontal():
296
- yield Button("Add", variant="primary", id="add-btn")
297
- yield Button("Cancel", variant="default", id="cancel-btn")
297
+ yield Button(ButtonLabels.ADD, variant="primary", id="add-btn")
298
+ yield Button(ButtonLabels.CANCEL, variant="default", id="cancel-btn")
298
299
 
299
300
  def on_mount(self) -> None:
300
301
  self.query_one("#netfs-fields").display = False
@@ -328,10 +329,10 @@ class AddPoolModal(BaseModal[bool | None]):
328
329
  return
329
330
 
330
331
  if was_modified:
331
- self.app.show_success_message(f"Input sanitized: '{pool_name_raw}' changed to '{pool_name}'")
332
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=pool_name_raw, sanitized_input=pool_name))
332
333
 
333
334
  if not pool_name:
334
- self.app.show_error_message("Pool name is required.")
335
+ self.app.show_error_message(ErrorMessages.POOL_NAME_REQUIRED)
335
336
  return
336
337
 
337
338
  pool_details = {"name": pool_name, "type": pool_type}
@@ -339,7 +340,7 @@ class AddPoolModal(BaseModal[bool | None]):
339
340
  if pool_type == "dir":
340
341
  target_path = self.query_one("#dir-target-path-input", Input).value
341
342
  if not target_path:
342
- self.app.show_error_message("Target Path is required for `dir` type.")
343
+ self.app.show_error_message(ErrorMessages.TARGET_PATH_REQUIRED_FOR_DIR)
343
344
  return
344
345
  if target_path == "/var/lib/libvirt/images/":
345
346
  target_path = os.path.join(target_path, pool_name)
@@ -350,7 +351,7 @@ class AddPoolModal(BaseModal[bool | None]):
350
351
  host = self.query_one("#netfs-host-input", Input).value
351
352
  source_path = self.query_one("#netfs-source-path-input", Input).value
352
353
  if not all([target_path, host, source_path]):
353
- self.app.show_error_message("For `netfs`, all fields are required.")
354
+ self.app.show_error_message(ErrorMessages.NETFS_FIELDS_REQUIRED)
354
355
  return
355
356
  pool_details["target"] = target_path
356
357
  pool_details["format"] = netfs_format
@@ -378,13 +379,13 @@ class AddPoolModal(BaseModal[bool | None]):
378
379
  )
379
380
  self.app.call_from_thread(
380
381
  self.app.show_success_message,
381
- f"Storage pool '{pool_details['name']}' created and started."
382
+ SuccessMessages.STORAGE_POOL_CREATED_TEMPLATE.format(name=pool_details['name'])
382
383
  )
383
384
  self.app.call_from_thread(self.dismiss, True)
384
385
  except Exception as e:
385
386
  self.app.call_from_thread(
386
387
  self.app.show_error_message,
387
- f"Error creating storage pool: {e}"
388
+ ErrorMessages.ERROR_CREATING_STORAGE_POOL_TEMPLATE.format(error=e)
388
389
  )
389
390
 
390
391
  self.app.worker_manager.run(
@@ -399,13 +400,13 @@ class CreateVolumeModal(BaseModal[dict | None]):
399
400
 
400
401
  def compose(self) -> ComposeResult:
401
402
  with Vertical(id="create-volume-dialog"):
402
- yield Label("Create New Storage Volume")
403
+ yield Label(StaticText.CREATE_NEW_STORAGE_VOLUME)
403
404
  yield Input(placeholder="Volume Name (e.g., new_disk.qcow2)", id="vol-name-input")
404
405
  yield Input(placeholder="Size in GB (e.g., 10)", id="vol-size-input", type="integer")
405
406
  yield Select([("qcow2", "qcow2"), ("raw", "raw")], id="vol-format-select", value="qcow2")
406
407
  with Horizontal():
407
- yield Button("Create", variant="primary", id="create-btn")
408
- yield Button("Cancel", variant="default", id="cancel-btn")
408
+ yield Button(ButtonLabels.CREATE, variant="primary", id="create-btn")
409
+ yield Button(ButtonLabels.CANCEL, variant="default", id="cancel-btn")
409
410
 
410
411
  def on_button_pressed(self, event: Button.Pressed) -> None:
411
412
  if event.button.id == "create-btn":
@@ -420,16 +421,16 @@ class CreateVolumeModal(BaseModal[dict | None]):
420
421
  return
421
422
 
422
423
  if was_modified:
423
- self.app.show_success_message(f"Input sanitized: '{name_raw}' changed to '{name}'")
424
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=name_raw, sanitized_input=name))
424
425
 
425
426
  if not name or not size:
426
- self.app.show_error_message("Name and size are required.")
427
+ self.app.show_error_message(ErrorMessages.NAME_AND_SIZE_REQUIRED)
427
428
  return
428
429
 
429
430
  try:
430
431
  size_gb = int(size)
431
432
  except ValueError:
432
- self.app.show_error_message("Size must be an integer.")
433
+ self.app.show_error_message(ErrorMessages.SIZE_MUST_BE_INTEGER)
433
434
  return
434
435
 
435
436
  self.dismiss({'name': name, 'size_gb': size_gb, 'format': vol_format})
@@ -449,26 +450,26 @@ class EditDiskModal(BaseModal[dict | None]):
449
450
  device_options = [("disk", "disk"), ("cdrom", "cdrom"), ("lun", "lun")]
450
451
 
451
452
  with Vertical(id="edit-disk-dialog"):
452
- yield Label(f"Edit Disk: {self.disk_info['path']}", id="edit-disk-title")
453
+ yield Label(StaticText.EDIT_DISK_TITLE.format(path=self.disk_info['path']), id="edit-disk-title")
453
454
 
454
- yield Label("Device Type:")
455
+ yield Label(StaticText.DEVICE_TYPE)
455
456
  yield Select(device_options, value=self.disk_info.get('device_type') or 'disk', id="edit-device-type", disabled=not self.is_stopped)
456
457
 
457
- yield Label("Bus Type:")
458
+ yield Label(StaticText.BUS_TYPE)
458
459
  yield Select(bus_options, value=self.disk_info.get('bus'), id="edit-bus-type", disabled=not self.is_stopped)
459
460
 
460
- yield Label("Cache Mode:")
461
+ yield Label(StaticText.CACHE_MODE)
461
462
  yield Select(cache_options, value=self.disk_info.get('cache_mode') or 'none', id="edit-cache-mode", disabled=not self.is_stopped)
462
463
 
463
- yield Label("Discard Mode:")
464
+ yield Label(StaticText.DISCARD_MODE)
464
465
  yield Select(discard_options, value=self.disk_info.get('discard_mode') or 'unmap', id="edit-discard-mode", disabled=not self.is_stopped)
465
466
 
466
467
  if not self.is_stopped:
467
- yield Label("VM must be stopped to edit disk settings.", classes="warning")
468
+ yield Label(StaticText.VM_MUST_BE_STOPPED_EDIT_DISK, classes="warning")
468
469
 
469
470
  with Horizontal(classes="modal-buttons"):
470
- yield Button("Apply", variant="primary", id="apply-disk-edit", disabled=not self.is_stopped)
471
- yield Button("Cancel", id="cancel-disk-edit")
471
+ yield Button(ButtonLabels.APPLY, variant="primary", id="apply-disk-edit", disabled=not self.is_stopped)
472
+ yield Button(ButtonLabels.CANCEL, id="cancel-disk-edit")
472
473
 
473
474
  @on(Button.Pressed, "#apply-disk-edit")
474
475
  def on_apply(self):
@@ -495,26 +496,26 @@ class MoveVolumeModal(BaseModal[dict]):
495
496
 
496
497
  def compose(self) -> ComposeResult:
497
498
  with Vertical(id="move-volume-dialog"):
498
- yield Label(f"Move Volume: {self.volume_name}", id="move-volume-title")
499
- yield Static(f"From Pool: {self.source_pool_name}", classes="label-like")
499
+ yield Label(StaticText.MOVE_VOLUME_TITLE.format(volume_name=self.volume_name), id="move-volume-title")
500
+ yield Static(StaticText.FROM_POOL.format(source_pool_name=self.source_pool_name), classes="label-like")
500
501
 
501
502
  pools = list_storage_pools(self.conn)
502
503
  # Filter out the source pool from the destination choices
503
504
  dest_pools = [(p['name'], p['name']) for p in pools if p['name'] != self.source_pool_name and p['status'] == 'active']
504
505
 
505
506
  if not dest_pools:
506
- yield Label("No other active pools available to move to.", classes="error-text")
507
- yield Button("Cancel", id="cancel-btn", variant="default")
507
+ yield Label(StaticText.NO_OTHER_ACTIVE_POOLS, classes="error-text")
508
+ yield Button(ButtonLabels.CANCEL, id="cancel-btn", variant="default")
508
509
  else:
509
- yield Label("Destination Pool:", classes="label-like")
510
+ yield Label(StaticText.DESTINATION_POOL, classes="label-like")
510
511
  yield Select(dest_pools, id="dest-pool-select")
511
512
 
512
- yield Label("New Volume Name:", classes="label-like")
513
+ yield Label(StaticText.NEW_VOLUME_NAME, classes="label-like")
513
514
  yield Input(value=self.volume_name, id="new-volume-name-input")
514
515
 
515
516
  with Horizontal(classes="button-bar"):
516
- yield Button("Move", id="move-btn", variant="primary")
517
- yield Button("Cancel", id="cancel-btn", variant="default")
517
+ yield Button(ButtonLabels.MOVE, id="move-btn", variant="primary")
518
+ yield Button(ButtonLabels.CANCEL, id="cancel-btn", variant="default")
518
519
 
519
520
  def on_button_pressed(self, event: Button.Pressed) -> None:
520
521
  if event.button.id == "cancel-btn":
@@ -530,7 +531,7 @@ class MoveVolumeModal(BaseModal[dict]):
530
531
  return
531
532
 
532
533
  if was_modified:
533
- self.app.show_success_message(f"Input sanitized: '{new_name_input_raw}' changed to '{new_name}'")
534
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=new_name_input_raw, sanitized_input=new_name))
534
535
 
535
536
  if dest_pool_select.value and new_name:
536
537
  self.dismiss({
@@ -538,7 +539,7 @@ class MoveVolumeModal(BaseModal[dict]):
538
539
  "new_name": new_name
539
540
  })
540
541
  else:
541
- self.app.show_error_message("Destination pool and new name are required.")
542
+ self.app.show_error_message(ErrorMessages.DEST_POOL_AND_NAME_REQUIRED)
542
543
 
543
544
 
544
545
  class AttachVolumeModal(BaseModal[dict | None]):
@@ -550,14 +551,14 @@ class AttachVolumeModal(BaseModal[dict | None]):
550
551
 
551
552
  def compose(self) -> ComposeResult:
552
553
  with Vertical(id="attach-volume-dialog"):
553
- yield Label("Attach Existing Disk as Volume")
554
+ yield Label(StaticText.ATTACH_EXISTING_DISK_AS_VOLUME)
554
555
  yield Input(placeholder="Volume Name (e.g., existing_disk.qcow2)", id="vol-name-input", disabled=True)
555
556
  with Horizontal():
556
557
  yield Input(placeholder="Path to disk image", id="vol-path-input")
557
- yield Button("Browse", id="browse-vol-btn")
558
+ yield Button(ButtonLabels.BROWSE, id="browse-vol-btn")
558
559
  with Horizontal():
559
- yield Button("Attach", variant="primary", id="attach-btn")
560
- yield Button("Cancel", variant="default", id="cancel-btn")
560
+ yield Button(ButtonLabels.ATTACH, variant="primary", id="attach-btn")
561
+ yield Button(ButtonLabels.CANCEL, variant="default", id="cancel-btn")
561
562
 
562
563
  @on(Input.Changed, "#vol-path-input")
563
564
  def on_vol_path_input_changed(self, event: Input.Changed) -> None:
@@ -567,7 +568,7 @@ class AttachVolumeModal(BaseModal[dict | None]):
567
568
  try:
568
569
  sanitized_name, was_modified = _sanitize_input(os.path.basename(event.value))
569
570
  if was_modified:
570
- self.app.show_success_message(f"Input sanitized: '{os.path.basename(event.value)}' changed to '{sanitized_name}'")
571
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=os.path.basename(event.value), sanitized_input=sanitized_name))
571
572
 
572
573
  vol_name_input.value = sanitized_name
573
574
  except ValueError as e:
@@ -610,10 +611,10 @@ class AttachVolumeModal(BaseModal[dict | None]):
610
611
  return
611
612
 
612
613
  if was_modified:
613
- self.app.show_success_message(f"Input sanitized: '{name_raw}' changed to '{name}'")
614
+ self.app.show_success_message(SuccessMessages.INPUT_SANITIZED.format(original_input=name_raw, sanitized_input=name))
614
615
 
615
616
  if not name or not path or not vol_format:
616
- self.app.show_error_message("Name, path, and format are required. Ensure a file is selected.")
617
+ self.app.show_error_message(ErrorMessages.NAME_PATH_FORMAT_REQUIRED)
617
618
  return
618
619
 
619
620
  self.dismiss({'name': name, 'path': path, 'format': vol_format})
@@ -0,0 +1,137 @@
1
+ """
2
+ Modal for displaying Host Resource Dashboard.
3
+ """
4
+ import libvirt
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical, Horizontal, Grid
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Label, Button, ProgressBar, Static, Rule, TabbedContent, TabPane
9
+ from textual import on, work
10
+
11
+ from .base_modals import BaseModal
12
+ from ..constants import StaticText, ButtonLabels
13
+ from ..libvirt_utils import get_host_resources, get_total_vm_allocation
14
+ from ..constants import TabTitles
15
+
16
+ class HostDashboardModal(BaseModal[None]):
17
+ """Modal to show host resource usage and VM allocations."""
18
+
19
+ def __init__(self, conn: libvirt.virConnect, server_name: str = "Unknown") -> None:
20
+ super().__init__()
21
+ self.conn = conn
22
+ self.server_name = server_name
23
+ self.host_resources = get_host_resources(conn)
24
+ self.vm_allocation = {}
25
+
26
+ def compose(self) -> ComposeResult:
27
+ with Vertical(id="host-dashboard-dialog"):
28
+ yield Label(f"{StaticText.HOST_RESOURCE_DASHBOARD} - {self.server_name}", id="dialog-title")
29
+
30
+ with Vertical(classes="info-section-dashboard"):
31
+ yield Label(StaticText.HOST_DETAILS, classes="section-title-dashboard")
32
+ with Grid(classes="host-info-grid"):
33
+ yield Label(StaticText.MODEL)
34
+ yield Label(self.host_resources.get('model', 'N/A'))
35
+ yield Label("CPUs:")
36
+ yield Label(f"{self.host_resources.get('total_cpus', 0)} ({self.host_resources.get('mhz', 0)} MHz)")
37
+ yield Label(StaticText.TOPOLOGY)
38
+ yield Label(f"{self.host_resources.get('nodes', 1)} nodes, {self.host_resources.get('sockets', 1)} sockets, {self.host_resources.get('cores', 1)} cores, {self.host_resources.get('threads', 1)} threads")
39
+
40
+ with Vertical(classes="info-section-dashboard"):
41
+ yield Label(StaticText.MEMORY_USAGE, classes="section-title-dashboard")
42
+ total_mem = self.host_resources.get('available_memory', 0)
43
+ free_mem = self.host_resources.get('free_memory', 0)
44
+ used_mem = total_mem - free_mem
45
+ mem_pct = (used_mem / total_mem * 100) if total_mem > 0 else 0
46
+
47
+ with Horizontal(classes="usage-row"):
48
+ yield Label(f"{used_mem/1024:.1f} GB / {total_mem/1024:.1f} GB ({mem_pct:.1f}%)")
49
+ yield ProgressBar(total=100, show_percentage=False, show_eta=False, id="mem-bar")
50
+
51
+ with Vertical(classes="info-section-dashboard"):
52
+ yield Label(StaticText.VM_ALLOCATION, classes="section-title-dashboard")
53
+ yield Label(StaticText.WAITING_TO_START_COLLECTION, id="progress-label")
54
+
55
+ with TabbedContent():
56
+ with TabPane(TabTitles.ACTIVE_ALLOCATION, id="tab-active"):
57
+ # Active CPU Allocation
58
+ yield Label(f"{StaticText.ALLOCATED_VCPUS} (Active):", id="cpu-alloc-label-active")
59
+ yield ProgressBar(total=100, show_percentage=False, show_eta=False, id="cpu-alloc-bar-active")
60
+ yield Rule()
61
+ # Active Memory Allocation
62
+ yield Label(f"{StaticText.ALLOCATED_MEMORY} (Active):", id="mem-alloc-label-active")
63
+ yield ProgressBar(total=100, show_percentage=False, show_eta=False, id="mem-alloc-bar-active")
64
+ with TabPane(TabTitles.TOTAL_ALLOCATION, id="tab-total"):
65
+ # Total CPU Allocation
66
+ yield Label(f"{StaticText.ALLOCATED_VCPUS} (Total):", id="cpu-alloc-label-total")
67
+ yield ProgressBar(total=100, show_percentage=False, show_eta=False, id="cpu-alloc-bar-total")
68
+ yield Rule()
69
+ # Total Memory Allocation
70
+ yield Label(f"{StaticText.ALLOCATED_MEMORY} (Total):", id="mem-alloc-label-total")
71
+ yield ProgressBar(total=100, show_percentage=False, show_eta=False, id="mem-alloc-bar-total")
72
+
73
+ with Horizontal(classes="dialog-buttons"):
74
+ yield Button(ButtonLabels.CLOSE, id="close-btn", variant="primary")
75
+
76
+ def on_mount(self) -> None:
77
+ # Update progress bars
78
+ total_mem = self.host_resources.get('available_memory', 0)
79
+ free_mem = self.host_resources.get('free_memory', 0)
80
+ used_mem = total_mem - free_mem
81
+ mem_pct = (used_mem / total_mem * 100) if total_mem > 0 else 0
82
+ self.query_one("#mem-bar", ProgressBar).update(progress=mem_pct)
83
+
84
+ self.compute_vm_allocation()
85
+
86
+ @work(thread=True)
87
+ def compute_vm_allocation(self) -> None:
88
+ def progress(current, total):
89
+ self.app.call_from_thread(self.update_progress, current, total)
90
+
91
+ self.vm_allocation = get_total_vm_allocation(self.conn, progress_callback=progress)
92
+ self.app.call_from_thread(self.update_dashboard)
93
+
94
+ def update_progress(self, current: int, total: int) -> None:
95
+ try:
96
+ lbl = self.query_one("#progress-label", Label)
97
+ lbl.update(StaticText.COLLECTING_VM_INFO.format(current=current, total=total))
98
+ except:
99
+ pass
100
+
101
+ def update_dashboard(self) -> None:
102
+ # Hide progress label
103
+ try:
104
+ self.query_one("#progress-label", Label).styles.display = "none"
105
+ except:
106
+ pass
107
+
108
+ total_host_cpus = self.host_resources.get('total_cpus', 1)
109
+ total_mem = self.host_resources.get('available_memory', 0)
110
+
111
+ # Total Allocation
112
+ alloc_cpus_total = self.vm_allocation.get('total_allocated_vcpus', 0)
113
+ cpu_alloc_pct_total = (alloc_cpus_total / total_host_cpus * 100)
114
+
115
+ self.query_one("#cpu-alloc-bar-total", ProgressBar).update(progress=cpu_alloc_pct_total)
116
+ self.query_one("#cpu-alloc-label-total", Label).update(f"{StaticText.ALLOCATED_VCPUS} (Total): {alloc_cpus_total} / {total_host_cpus} ({cpu_alloc_pct_total:.1f}%)")
117
+
118
+ alloc_mem_total = self.vm_allocation.get('total_allocated_memory', 0)
119
+ mem_alloc_pct_total = (alloc_mem_total / total_mem * 100) if total_mem > 0 else 0
120
+
121
+ self.query_one("#mem-alloc-bar-total", ProgressBar).update(progress=mem_alloc_pct_total)
122
+ self.query_one("#mem-alloc-label-total", Label).update(f"{StaticText.ALLOCATED_MEMORY} (Total): {alloc_mem_total/1024:.1f} GB / {total_mem/1024:.1f} GB ({mem_alloc_pct_total:.1f}%)")
123
+
124
+ # Active Allocation
125
+ alloc_cpus_active = self.vm_allocation.get('active_allocated_vcpus', 0)
126
+ cpu_alloc_pct_active = (alloc_cpus_active / total_host_cpus * 100)
127
+ self.query_one("#cpu-alloc-bar-active", ProgressBar).update(progress=cpu_alloc_pct_active)
128
+ self.query_one("#cpu-alloc-label-active", Label).update(f"{StaticText.ALLOCATED_VCPUS} (Active): {alloc_cpus_active} / {total_host_cpus} ({cpu_alloc_pct_active:.1f}%)")
129
+
130
+ alloc_mem_active = self.vm_allocation.get('active_allocated_memory', 0)
131
+ mem_alloc_pct_active = (alloc_mem_active / total_mem * 100) if total_mem > 0 else 0
132
+ self.query_one("#mem-alloc-bar-active", ProgressBar).update(progress=mem_alloc_pct_active)
133
+ self.query_one("#mem-alloc-label-active", Label).update(f"{StaticText.ALLOCATED_MEMORY} (Active): {alloc_mem_active/1024:.1f} GB / {total_mem/1024:.1f} GB ({mem_alloc_pct_active:.1f}%)")
134
+
135
+ @on(Button.Pressed, "#close-btn")
136
+ def on_close_btn_pressed(self) -> None:
137
+ self.dismiss()