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
|
# 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`.
> The `vm-manager` paths and the `panel toggle` IPC below are historical. See
> `desktop/README.md`.
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 .
`SUPER+v` toggles it, through an IPC call, 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/vm-manager")
-- keybindings.lua
hl.bind(mainMod .. " + v", hl.dsp.exec_cmd(
"qs -p ~/Programming/GIT/quickshell/vm-manager ipc call panel toggle"))
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
`VmPanel.qml`, defaulting to `DP-3`, and falls back to the first screen when
that one is not connected. It dims the rest of that monitor, because the
secondary screen usually has a real window on it, and 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 drawer
is open: `Virsh.sampling` follows the panel's `open`. A closed panel 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. A filled
`Item` with `focus: true` catches it, and takes focus back when the TextInput
in a delete confirmation gives it up.
## 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,
})
|