Skip to content

Commit 75a9ee5

Browse files
committed
EXPERIMENTAL Add top-like monitoring tool
Inspiration and code from qui-domains. Still some rough edges: - no docstrings - missing window restoration on exit - not scrollable when screen is too small for the amount of running qubes, "xl top" is scrollable - missing stubdomains, we have it's vm-stats event from my other libvirt cache PR in core-admin, but here we are only registering qubes that are in the VMCollection - header background is taking more space than necessary
1 parent 4e4fa79 commit 75a9ee5

4 files changed

Lines changed: 291 additions & 0 deletions

File tree

doc/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@
366366
u'Manage tags on a qube', _man_pages_author, 1),
367367
('manpages/qvm-template', 'qvm-template',
368368
u'Manage templates', _man_pages_author, 1),
369+
('manpages/qvm-top', 'qvm-top',
370+
u'Top-like monitoring tool', _man_pages_author, 1),
369371
('manpages/qvm-unpause', 'qvm-unpause',
370372
u'Pause a qube', _man_pages_author, 1),
371373
('manpages/qvm-volume', 'qvm-volume',

doc/manpages/qvm-top.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.. program:: qvm-top
2+
3+
:program:`qvm-top` -- top-like monitoring tool
4+
==============================================
5+
6+
Synopsis
7+
--------
8+
9+
:command:`qvm-top`
10+
11+
Notes
12+
-----
13+
14+
Quit by pressing `q`, `Q`, or `ESC`.
15+
16+
Authors
17+
-------
18+
19+
| Benjamin Grande <ben.grande.b at gmail dot com>
20+
| For complete author list see: https://github.com/QubesOS/qubes-core-admin-client.git
21+
22+
.. vim: ts=3 sw=3 et tw=80

doc/qubesadmin.tools.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ qubesadmin\.tools\.qvm\_template\_postprocess module
196196
:undoc-members:
197197
:show-inheritance:
198198

199+
qubesadmin\.tools\.qvm\_top module
200+
----------------------------------------------------
201+
202+
.. automodule:: qubesadmin.tools.qvm_top
203+
:members:
204+
:undoc-members:
205+
:show-inheritance:
206+
199207
qubesadmin\.tools\.qvm\_unpause module
200208
--------------------------------------
201209

qubesadmin/tools/qvm_top.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env python3
2+
3+
## SPDX-FileCopyrightText: 2025 Benjamin Grande M. S. <ben.grande.b@gmail.com>
4+
##
5+
## SPDX-License-Identifier: GPL-2.0-only
6+
7+
"""
8+
Top-like info for Qubes.
9+
"""
10+
11+
import asyncio
12+
import curses
13+
import sys
14+
import time
15+
import traceback
16+
17+
import qubesadmin
18+
from qubesadmin.events import EventsDispatcher
19+
20+
21+
STATE_DICTIONARY = {
22+
"domain-pre-start": "Transient",
23+
"domain-start": "Running",
24+
"domain-start-failed": "Halted",
25+
"domain-paused": "Paused",
26+
"domain-unpaused": "Running",
27+
"domain-shutdown": "Halted",
28+
"domain-pre-shutdown": "Transient",
29+
"domain-shutdown-failed": "Running",
30+
}
31+
32+
33+
class Stats:
34+
# pylint: disable=missing-class-docstring
35+
36+
def __init__(self, vm, state=None):
37+
super().__init__()
38+
self.vm = vm
39+
self.memory = 0
40+
self.cpu = 0
41+
self.state = state
42+
43+
def update_stats(self, memory_kb, cpu_usage):
44+
# pylint: disable=missing-function-docstring
45+
self.memory = int(int(memory_kb) / 1024)
46+
self.cpu = int(cpu_usage)
47+
48+
def update_state(self, state):
49+
# pylint: disable=missing-function-docstring
50+
self.state = state
51+
52+
53+
class Monitor:
54+
# pylint: disable=missing-class-docstring
55+
56+
def __init__(self, app, dispatcher, stats_dispatcher, stdscr):
57+
self.entries = {}
58+
self.stdscr = stdscr
59+
self.app = app
60+
self.dispatcher = dispatcher
61+
self.stats_dispatcher = stats_dispatcher
62+
self.register_events()
63+
self.skip_draw = False
64+
65+
def run(self):
66+
# pylint: disable=missing-function-docstring
67+
self.initialize_entries()
68+
69+
def initialize_entries(self):
70+
# pylint: disable=missing-function-docstring
71+
# Add AdminVMS
72+
self.add_domain_item(
73+
submitter=None, vm=self.app.domains["dom0"], event=None
74+
)
75+
# and the rest of them
76+
for vm in sorted(
77+
[vm for vm in self.app.domains if vm not in self.entries]
78+
):
79+
self.add_domain_item(submitter=None, vm=vm, event=None)
80+
81+
for item in self.entries.values():
82+
try:
83+
if item.vm and item.vm.is_running():
84+
item.state = item.vm.get_power_state()
85+
else:
86+
item.state = "Halted"
87+
except qubesadmin.exc.QubesPropertyAccessError:
88+
item.state = "Halted"
89+
self.draw_table()
90+
91+
def update_stats(self, vm, _event, **kwargs):
92+
# pylint: disable=missing-function-docstring
93+
if vm not in self.entries:
94+
return
95+
self.entries[vm].update_stats(
96+
kwargs["memory_kb"], kwargs["cpu_usage"]
97+
)
98+
self.draw_table()
99+
100+
def refresh_all_items(self, _subject, _event, **_kwargs):
101+
# pylint: disable=missing-function-docstring,unused-argument
102+
self.skip_draw = True
103+
items_to_delete = [
104+
vm for vm in self.entries if vm not in self.app.domains
105+
]
106+
for vm in items_to_delete:
107+
self.remove_domain_item(submitter=None, vm=vm, event=None)
108+
for vm in self.app.domains:
109+
self.update_domain_item(vm, "")
110+
self.skip_draw = False
111+
self.draw_table()
112+
113+
def add_domain_item(self, submitter, event, vm, **_kwargs):
114+
# pylint: disable=missing-function-docstring,unused-argument
115+
try:
116+
vm = self.app.domains[str(vm)]
117+
except KeyError:
118+
# the VM was not created successfully or was deleted before the
119+
# event was fully handled
120+
return
121+
if vm in self.entries:
122+
return
123+
124+
state = self.get_state_from_event(event=event, vm=vm, default="Halted")
125+
entry = Stats(vm, state=state)
126+
self.entries[vm] = entry
127+
self.draw_table()
128+
129+
def remove_domain_item(self, submitter, event, vm, **_kwargs):
130+
# pylint: disable=missing-function-docstring,unused-argument
131+
if vm not in self.entries:
132+
return
133+
del self.entries[vm]
134+
self.draw_table()
135+
136+
def update_domain_item(self, vm, event, **kwargs):
137+
# pylint: disable=missing-function-docstring
138+
try:
139+
item = self.entries[vm]
140+
except qubesadmin.exc.QubesPropertyAccessError:
141+
print("Unexpected property access error", file=sys.stderr)
142+
traceback.print_exc()
143+
self.remove_domain_item(
144+
submitter=None, vm=vm, event=event, **kwargs
145+
)
146+
return
147+
except KeyError:
148+
self.add_domain_item(submitter=None, vm=vm, event=event)
149+
if not vm in self.entries:
150+
# VM not added - already removed?
151+
return
152+
item = self.entries[vm]
153+
154+
state = self.get_state_from_event(
155+
event=event, vm=vm, default="Transient"
156+
)
157+
if state is None:
158+
return
159+
item.update_state(state)
160+
self.draw_table()
161+
162+
def get_state_from_event(self, event, vm, default) -> str | None:
163+
# pylint: disable=missing-function-docstring
164+
if event in STATE_DICTIONARY:
165+
return STATE_DICTIONARY[event]
166+
try:
167+
state = vm.get_power_state()
168+
except Exception: # pylint: disable=broad-except
169+
if vm not in self.app.domains:
170+
return None
171+
state = default
172+
return state
173+
174+
def draw_table(self):
175+
"""
176+
Draw a top-like table about qubes statuses.
177+
"""
178+
if self.skip_draw:
179+
return
180+
self.stdscr.clear()
181+
height, _ = self.stdscr.getmaxyx()
182+
183+
self.stdscr.attron(curses.A_BOLD)
184+
self.stdscr.attron(curses.A_REVERSE)
185+
self.stdscr.addstr(0, 0, "Qube".ljust(40))
186+
self.stdscr.addstr(0, 40, "State".ljust(50))
187+
self.stdscr.addstr(0, 51, "MEM(%)".ljust(57))
188+
self.stdscr.addstr(0, 59, "CPU(%)".ljust(64))
189+
self.stdscr.attroff(curses.A_BOLD)
190+
self.stdscr.attroff(curses.A_REVERSE)
191+
192+
non_halted = [
193+
stats for stats in self.entries.values() if stats.state != "Halted"
194+
]
195+
for i, stats in enumerate(non_halted, start=1):
196+
if i >= height - 1:
197+
break
198+
self.stdscr.addstr(i, 0, stats.vm.name.ljust(40))
199+
self.stdscr.addstr(i, 40, stats.state.ljust(50))
200+
self.stdscr.addstr(i, 51, str(stats.memory).rjust(6))
201+
self.stdscr.addstr(i, 59, str(stats.cpu).rjust(6))
202+
203+
current_time = time.strftime("%Y-%m-%d %H:%M:%S")
204+
self.stdscr.addstr(height - 1, 0, f"qvm-top - {current_time}")
205+
self.stdscr.refresh()
206+
207+
def register_events(self):
208+
# pylint: disable=missing-function-docstring
209+
self.stats_dispatcher_handlers = [("vm-stats", self.update_stats)]
210+
self.dispatcher_handlers = [
211+
("connection-established", self.refresh_all_items),
212+
("domain-add", self.add_domain_item),
213+
("domain-delete", self.remove_domain_item),
214+
]
215+
for event in STATE_DICTIONARY:
216+
self.dispatcher_handlers.append((event, self.update_domain_item))
217+
for handler in self.stats_dispatcher_handlers:
218+
self.stats_dispatcher.add_handler(*handler)
219+
for handler in self.dispatcher_handlers:
220+
self.dispatcher.add_handler(*handler)
221+
222+
def unregister_events(self):
223+
# pylint: disable=missing-function-docstring
224+
for handler in self.stats_dispatcher_handlers:
225+
self.stats_dispatcher.remove_handler(*handler)
226+
for handler in self.dispatcher_handlers:
227+
self.dispatcher.remove_handler(*handler)
228+
229+
230+
async def main_async(stdscr) -> int:
231+
# pylint:disable=missing-function-docstring
232+
curses.curs_set(0)
233+
stdscr.clear()
234+
stdscr.timeout(0)
235+
236+
app = qubesadmin.Qubes()
237+
dispatcher = EventsDispatcher(app)
238+
stats_dispatcher = EventsDispatcher(app, api_method="admin.vm.Stats")
239+
tasks = [
240+
asyncio.ensure_future(dispatcher.listen_for_events()),
241+
asyncio.ensure_future(stats_dispatcher.listen_for_events()),
242+
]
243+
top = Monitor(
244+
app=app,
245+
dispatcher=dispatcher,
246+
stats_dispatcher=stats_dispatcher,
247+
stdscr=stdscr,
248+
)
249+
top.run()
250+
return await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
251+
252+
253+
def main() -> int:
254+
# pylint:disable=missing-function-docstring
255+
return asyncio.run(curses.wrapper(main_async))
256+
257+
258+
if __name__ == "__main__":
259+
sys.exit(main())

0 commit comments

Comments
 (0)