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
|
# vm-manager
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.
## Destructive actions
`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,
})
|