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.
- {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/METADATA +1 -1
- virtui_manager-1.4.0.dist-info/RECORD +76 -0
- vmanager/constants.py +739 -108
- vmanager/dialog.css +24 -0
- vmanager/firmware_manager.py +4 -1
- vmanager/i18n.py +32 -0
- vmanager/libvirt_utils.py +132 -3
- vmanager/locales/de/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/de/LC_MESSAGES/virtui-manager.po +3158 -0
- vmanager/locales/fr/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/fr/LC_MESSAGES/virtui-manager.po +3155 -0
- vmanager/locales/it/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/it/LC_MESSAGES/virtui-manager.po +3132 -0
- vmanager/locales/virtui-manager.pot +3033 -0
- vmanager/modals/bulk_modals.py +13 -12
- vmanager/modals/cache_stats_modal.py +6 -5
- vmanager/modals/capabilities_modal.py +133 -0
- vmanager/modals/config_modal.py +25 -24
- vmanager/modals/cpu_mem_pc_modals.py +22 -21
- vmanager/modals/custom_migration_modal.py +10 -9
- vmanager/modals/disk_pool_modals.py +60 -59
- vmanager/modals/host_dashboard_modal.py +137 -0
- vmanager/modals/host_stats.py +199 -0
- vmanager/modals/howto_disk_modal.py +2 -1
- vmanager/modals/howto_network_modal.py +2 -1
- vmanager/modals/howto_overlay_modal.py +2 -1
- vmanager/modals/howto_ssh_modal.py +2 -1
- vmanager/modals/howto_virtiofs_modal.py +2 -1
- vmanager/modals/input_modals.py +11 -10
- vmanager/modals/log_modal.py +2 -1
- vmanager/modals/migration_modals.py +21 -19
- vmanager/modals/network_modals.py +45 -36
- vmanager/modals/provisioning_modals.py +56 -56
- vmanager/modals/select_server_modals.py +8 -7
- vmanager/modals/selection_modals.py +7 -6
- vmanager/modals/server_modals.py +24 -23
- vmanager/modals/server_prefs_modals.py +78 -71
- vmanager/modals/utils_modals.py +10 -9
- vmanager/modals/virsh_modals.py +3 -2
- vmanager/modals/virtiofs_modals.py +6 -5
- vmanager/modals/vm_type_info_modal.py +2 -1
- vmanager/modals/vmanager_modals.py +19 -19
- vmanager/modals/vmcard_dialog.py +57 -57
- vmanager/modals/vmdetails_modals.py +115 -123
- vmanager/modals/xml_modals.py +3 -2
- vmanager/network_manager.py +4 -1
- vmanager/storage_manager.py +157 -39
- vmanager/utils.py +54 -7
- vmanager/vm_actions.py +48 -24
- vmanager/vm_migration.py +4 -1
- vmanager/vm_queries.py +67 -25
- vmanager/vm_service.py +8 -5
- vmanager/vmanager.css +55 -1
- vmanager/vmanager.py +247 -120
- vmanager/vmcard.css +3 -1
- vmanager/vmcard.py +270 -205
- vmanager/webconsole_manager.py +22 -22
- virtui_manager-1.1.6.dist-info/RECORD +0 -65
- {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/WHEEL +0 -0
- {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/entry_points.txt +0 -0
- {virtui_manager-1.1.6.dist-info → virtui_manager-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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(
|
|
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(
|
|
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(
|
|
90
|
-
yield Button(
|
|
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(
|
|
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(
|
|
114
|
-
yield 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(
|
|
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(
|
|
134
|
-
yield Button(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
291
|
+
yield Label(StaticText.SOURCE_HOSTNAME)
|
|
291
292
|
yield Input(placeholder="nfs.example.com", id="netfs-host-input")
|
|
292
|
-
yield Label(
|
|
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(
|
|
297
|
-
yield Button(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
408
|
-
yield Button(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
453
|
+
yield Label(StaticText.EDIT_DISK_TITLE.format(path=self.disk_info['path']), id="edit-disk-title")
|
|
453
454
|
|
|
454
|
-
yield Label(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
468
|
+
yield Label(StaticText.VM_MUST_BE_STOPPED_EDIT_DISK, classes="warning")
|
|
468
469
|
|
|
469
470
|
with Horizontal(classes="modal-buttons"):
|
|
470
|
-
yield Button(
|
|
471
|
-
yield Button(
|
|
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(
|
|
499
|
-
yield Static(
|
|
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(
|
|
507
|
-
yield Button(
|
|
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(
|
|
510
|
+
yield Label(StaticText.DESTINATION_POOL, classes="label-like")
|
|
510
511
|
yield Select(dest_pools, id="dest-pool-select")
|
|
511
512
|
|
|
512
|
-
yield Label(
|
|
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(
|
|
517
|
-
yield Button(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
558
|
+
yield Button(ButtonLabels.BROWSE, id="browse-vol-btn")
|
|
558
559
|
with Horizontal():
|
|
559
|
-
yield Button(
|
|
560
|
-
yield Button(
|
|
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(
|
|
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(
|
|
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(
|
|
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()
|