1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
|
# vm-manager
> **Now the `vm` module of the `desktop/` shell.** It is no longer a
> standalone component: start the shell with `qs -p desktop` and open the page
> from the drawer, or deep-link with `qs -p desktop ipc call drawer open vm`.
> See `desktop/README.md`; the `blur-vm-manager` namespace in the Theme section
> below is historical, since this page now rides the drawer's
> `quickshell-desktop` surface.
A drawer for libvirt virtual machines: state, live statistics, the actions
that make sense in the current state, and the snapshot list. It replaces the
menu half of `rofi-qemu.sh`, which could only ever show a list of strings.
┌─ buildsystem ───────────────── ● running ─┐
│ CPU 4% RAM 0.7/16 GB Disk 60/130 GB │
│ 172.16.34.20 │
│ [Shutdown] [Reboot] [Suspend] [Reset] │
│ snapshots │
│ working 2026-03-06 10:27 │
└───────────────────────────────────────────┘
## Running it
qs -p ./desktop
The vm page is reached from the drawer, or deep-linked directly with
`qs -p ./desktop ipc call drawer open vm`. `SUPER+v` does exactly that, so the
shell has to be running first. Both are wired in the Hyprland config:
-- autostart.lua
hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop")
-- keybindings.lua
hl.bind(mainMod .. " + v", hl.dsp.exec_cmd(
"qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open vm"))
Write those two paths out in full in the real config: `exec_cmd` runs the
command directly, with no shell to expand `~`.
The drawer opens on the monitor named by the `monitor` property at the top of
`Drawer.qml`, defaulting to `DP-1`, and falls back to the first screen when
that one is not connected. It takes keyboard focus so Escape closes it and a
delete can be confirmed by typing.
## Where the numbers come from
Everything shells out to `virsh`, the same commands the rofi script used. No
libvirt bindings, no new dependency.
| Row | Source |
| ---------- | ------------------------------------------------- |
| state | `virsh domstats --state` |
| CPU | `cpu.time` sampled twice, divided by vCPU count |
| RAM | `virsh dommemstat`: `actual - usable` |
| Disk | guest agent `guest-get-fsinfo`, the `/` mount |
| address | `virsh domifaddr --source agent` |
| snapshots | `virsh snapshot-list` |
Three of those need **qemu-guest-agent running inside the guest**. It is not
up for the first few seconds after boot, and another VM might not have it at
all, so those rows show `—` and the header says `agent starting` rather than
substituting a host-side figure. That distinction matters: libvirt's own
`balloon.current` is memory *allocated* to the VM, which on a VM that has
booted reads as 100% forever, and `block.0.allocation` is qcow2 growth on the
host, not usage inside the guest. Showing either in place of the real number
would be quietly wrong, so they are not used as a fallback.
## Live without polling
`virsh event --all --loop` streams lifecycle changes, and that process runs
for the whole session, panel open or not. Starting a VM from `virsh` or
virt-manager updates the drawer, and opening it shows current state rather
than whatever was true last time.
Statistics do need sampling, on a 2 second timer, but only while the vm page
is constructed: `Virsh.sampling` follows the page, set in the page's
`Component.onCompleted` and cleared in `Component.onDestruction`. The page is
lazily loaded, so a closed page costs one idle process waiting on an event
socket.
## Two things that bite
**A config with no visible window exits.** The drawer is closed most of the
time, so the shell holds itself open with a 1x1 transparent window with an
empty mask, which is click-through and draws nothing. Without it the shell
loads, reports no error and quits, and since `SUPER+v` reaches it over IPC,
the key then has nothing to talk to and silently does nothing.
**Key events reach an item, not a window.** Setting `keyboardFocus` on the
layer shell is necessary but not sufficient: `Keys.onEscapePressed` on the
PanelWindow itself never fires, because no item inside holds focus. The panel
rectangle is focused as the ancestor of the page content, so a key from any
focused descendant bubbles up to it. The delete confirmation's `TextInput`
also handles Escape itself, cancelling the pending confirmation rather than
navigating away.
## Destructive actions
`Discard saved state` appears on a shut-off VM only when one actually exists,
and runs `virsh managedsave-remove`. A VM that was saved rather than shut down
restores that memory image on the next `start`, and when the image cannot be
restored the start fails every time with a QEMU `migrate-incoming` error while
the panel shows an ordinary `shut off`. Discarding it deletes the memory image
and nothing else, so the next start is a cold boot and the disk is untouched.
That is why it takes a confirmation but not a typed name.
Detection is `virsh dominfo`, grepped for `Managed save: yes`, once per VM on
every list refresh. `domstats` does not carry it and `virsh list --name` drops
the column that would.
`Reset`, `Force stop`, snapshot `Revert` and snapshot `Delete` each take one
confirmation click. `Delete VM` requires the machine's name to be typed,
because it runs `virsh undefine --remove-all-storage`, which erases the disk
image with no undo. The rofi script ran exactly that from a single menu
selection with no confirmation at all.
Failures are reported with `notify-send`, since a `virsh` error otherwise has
nowhere to go: the process output is not attached to a terminal.
## Theme
`Theme.qml` carries only fallbacks. The palette comes from
`~/.cache/wal/udt-palette.qml`, generated by `udt-accent` from
unified-desktop-theme's `palette.rasi`, and is watched: editing the palette
there recolours a running drawer without restarting it. State dots use the
green, yellow and red from that palette.
The frosting is Hyprland's, matched on this window's namespace:
hl.layer_rule({
name = "blur-vm-manager",
match = { namespace = "^(quickshell-vm-manager)$" },
blur = true,
xray = false,
ignore_alpha = 0.1,
})
|