virtui-manager 1.3.0__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.3.0.dist-info → virtui_manager-1.4.0.dist-info}/METADATA +1 -1
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.4.0.dist-info}/RECORD +25 -22
- vmanager/constants.py +8 -6
- vmanager/locales/de/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/de/LC_MESSAGES/virtui-manager.po +1186 -1040
- vmanager/locales/fr/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/fr/LC_MESSAGES/virtui-manager.po +532 -501
- vmanager/locales/it/LC_MESSAGES/virtui-manager.mo +0 -0
- vmanager/locales/it/LC_MESSAGES/virtui-manager.po +1171 -1051
- vmanager/locales/virtui-manager.pot +520 -499
- vmanager/modals/host_stats.py +199 -0
- vmanager/modals/migration_modals.py +1 -1
- vmanager/modals/utils_modals.py +1 -1
- vmanager/utils.py +15 -1
- vmanager/vm_actions.py +20 -0
- vmanager/vm_migration.py +4 -1
- vmanager/vmanager.css +9 -1
- vmanager/vmanager.py +70 -9
- vmanager/vmcard.css +3 -1
- vmanager/vmcard.py +113 -50
- vmanager/webconsole_manager.py +1 -1
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.4.0.dist-info}/WHEEL +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.4.0.dist-info}/entry_points.txt +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {virtui_manager-1.3.0.dist-info → virtui_manager-1.4.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
|
@@ -268,6 +268,20 @@ def check_websockify() -> bool:
|
|
|
268
268
|
return False
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
def check_tmux() -> bool:
|
|
272
|
+
"""
|
|
273
|
+
Checks if running inside a tmux session and tmux command is available.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
bool: True if inside tmux and tmux is installed, False otherwise
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
return os.environ.get("TMUX") is not None and shutil.which("tmux") is not None
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logging.error(f"Error checking tmux: {e}")
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
271
285
|
def check_is_firewalld_running() -> Union[str, bool]:
|
|
272
286
|
"""
|
|
273
287
|
Check if firewalld is running.
|
|
@@ -464,7 +478,7 @@ def generate_tooltip_markdown(
|
|
|
464
478
|
) -> str:
|
|
465
479
|
"""Generate tooltip markdown (pure function, cacheable)."""
|
|
466
480
|
mem_display = format_memory_display(memory)
|
|
467
|
-
cpu_display = f"{cpu}
|
|
481
|
+
cpu_display = f"{cpu} {cpu_model}" if cpu_model else str(cpu)
|
|
468
482
|
|
|
469
483
|
return (
|
|
470
484
|
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
|
|
@@ -68,12 +69,13 @@ from .vm_queries import (
|
|
|
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:
|
|
@@ -1320,7 +1354,7 @@ 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:
|
|
1326
1360
|
self.call_from_thread(self.show_success_message, SuccessMessages.BULK_ACTION_SUCCESS_TEMPLATE.format(action_type=action_type, count=len(successful_vms)))
|
|
@@ -1391,6 +1425,12 @@ class VMManagerTUI(App):
|
|
|
1391
1425
|
):
|
|
1392
1426
|
"""Worker to fetch, filter, and display VMs using a diffing strategy."""
|
|
1393
1427
|
try:
|
|
1428
|
+
# Update Host Stats
|
|
1429
|
+
if threading.current_thread() is threading.main_thread():
|
|
1430
|
+
self.host_stats.update_hosts(uris_to_query, self.servers)
|
|
1431
|
+
else:
|
|
1432
|
+
self.call_from_thread(self.host_stats.update_hosts, uris_to_query, self.servers)
|
|
1433
|
+
|
|
1394
1434
|
start_index = current_page * vms_per_page
|
|
1395
1435
|
end_index = start_index + vms_per_page
|
|
1396
1436
|
page_start = start_index if optimize_for_current_page else None
|
|
@@ -1826,7 +1866,7 @@ class VMManagerTUI(App):
|
|
|
1826
1866
|
else:
|
|
1827
1867
|
cpu = 0
|
|
1828
1868
|
memory = 0
|
|
1829
|
-
|
|
1869
|
+
|
|
1830
1870
|
logging.debug(f"Updating card {vm_internal_id} with status {status}")
|
|
1831
1871
|
# Update card on main thread
|
|
1832
1872
|
def update_ui():
|
|
@@ -1846,6 +1886,27 @@ class VMManagerTUI(App):
|
|
|
1846
1886
|
update_single_card, name=f"update_card_{vm_internal_id}"
|
|
1847
1887
|
)
|
|
1848
1888
|
|
|
1889
|
+
@on(SingleHostStat.ServerLabelClicked)
|
|
1890
|
+
def on_single_host_stat_server_label_clicked(self, message: SingleHostStat.ServerLabelClicked) -> None:
|
|
1891
|
+
"""Called when a server label is clicked in the host stats."""
|
|
1892
|
+
available_servers = []
|
|
1893
|
+
for uri in self.active_uris:
|
|
1894
|
+
name = uri
|
|
1895
|
+
for s in self.servers:
|
|
1896
|
+
if s['uri'] == uri:
|
|
1897
|
+
name = s['name']
|
|
1898
|
+
break
|
|
1899
|
+
available_servers.append({'name': name, 'uri': uri, 'color': self.get_server_color(uri)})
|
|
1900
|
+
|
|
1901
|
+
self.push_screen(
|
|
1902
|
+
FilterModal(
|
|
1903
|
+
current_search=self.search_text,
|
|
1904
|
+
current_status=self.sort_by,
|
|
1905
|
+
available_servers=available_servers,
|
|
1906
|
+
selected_servers=[message.server_uri] # Pre-select the clicked server
|
|
1907
|
+
)
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1849
1910
|
|
|
1850
1911
|
def main():
|
|
1851
1912
|
"""Entry point for vmanager TUI application."""
|
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; }
|