# 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 > `keystone-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. ## Containers Docker containers sit below the VMs, in `Docker.qml`, built the same way: the docker CLI, `docker events` running for the whole session, and `docker stats --no-stream` on the 2s timer only while the page is up. They are grouped by `com.docker.compose.project`, since a compose stack is a dozen containers that start and stop together; a container outside a project is a group of one. The card dot is green when the whole group runs, yellow when part of it does. CPU is docker's own figure, 100% per core. The event stream is filtered to lifecycle events: health checks emit `exec_*` events every few seconds per container, which unfiltered would turn the stream into a poll. It dies with the daemon, so it is retried every 10s. Verbs are start, stop, restart and resume, per container or for the whole group by id, so no compose file is needed; nothing removes a container. ## 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/udt/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, })