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.
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/METADATA +1 -1
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/RECORD +27 -23
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/entry_points.txt +1 -0
- vmanager/constants.py +15 -7
- vmanager/gui_wrapper.py +89 -0
- vmanager/i18n.py +2 -2
- vmanager/locale/de/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/{locales → locale}/de/LC_MESSAGES/virtui-manager.po +1217 -1037
- vmanager/locale/fr/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/{locales → locale}/fr/LC_MESSAGES/virtui-manager.po +564 -503
- vmanager/locale/it/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/{locales → locale}/it/LC_MESSAGES/virtui-manager.po +1205 -1050
- vmanager/{locales → locale}/virtui-manager.pot +555 -498
- vmanager/modals/host_stats.py +199 -0
- vmanager/modals/migration_modals.py +1 -1
- vmanager/modals/utils_modals.py +1 -1
- vmanager/utils.py +18 -1
- vmanager/vm_actions.py +20 -0
- vmanager/vm_migration.py +4 -1
- vmanager/vmanager.css +9 -1
- vmanager/vmanager.py +101 -16
- vmanager/vmcard.css +19 -1
- vmanager/vmcard.py +176 -72
- vmanager/webconsole_manager.py +1 -1
- vmanager/locales/fr/LC_MESSAGES/virtui-manager.mo +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/WHEEL +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.5.0.dist-info}/top_level.txt +0 -0
|
@@ -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")
|
vmanager/modals/utils_modals.py
CHANGED
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}
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
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
|
+
}
|