aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 16:23:36 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 16:23:36 +0200
commit1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18 (patch)
tree59d59bda8197a43009395b991dbc18fc93fe8a88
parent18fd64134f51ec99b123c91f79790c12ad5e1bb4 (diff)
downloadquickshell-1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18.tar.gz
quickshell-1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18.zip
feat(vm-manager): libvirt VM drawer
A top drawer on the secondary monitor showing every libvirt machine: its state, live statistics while it runs, the actions that make sense in that state, and the snapshot list. SUPER+v toggles it over IPC. This replaces the menu half of rofi-qemu.sh, which is still in ~/bin but no longer bound. A dmenu row can only ever be a string, so the script could not show what a machine is actually doing, and the snapshots it could create were never listed anywhere. Everything shells out to virsh, the same commands the script used, so there is no libvirt binding and no new dependency. State changes arrive by `virsh event --all --loop` rather than polling, which means a machine started from virt-manager or the CLI updates the drawer too. Statistics do need a 2 second timer, but it only runs while the drawer is open. Memory, filesystem usage and the address come from qemu-guest-agent, so they are absent for the first seconds after boot and on any machine without the agent. Those rows show a dash rather than falling back to libvirt's own figures: balloon.current is memory allocated rather than used and reads full forever, and block.allocation is qcow2 growth on the host rather than usage inside the guest. Either would be quietly wrong. Destructive actions are gated, which the rofi version did not do at all: Delete VM requires the name typed, since it runs undefine with --remove-all-storage and erases the disk image, and reset, force stop, snapshot revert and snapshot delete each take a confirmation click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
-rw-r--r--README.md1
-rw-r--r--vm-manager/Button.qml45
-rw-r--r--vm-manager/README.md97
-rw-r--r--vm-manager/Stat.qml44
-rw-r--r--vm-manager/Theme.qml47
-rw-r--r--vm-manager/Virsh.qml308
-rw-r--r--vm-manager/VmPanel.qml416
-rw-r--r--vm-manager/shell.qml41
8 files changed, 999 insertions, 0 deletions
diff --git a/README.md b/README.md
index 24adbac..1d32f07 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ repos stay independent, this one has no build-time dependency on that one.
## Implementations
volume-osd/ on-screen display for output and input volume
+ vm-manager/ libvirt VM drawer: state, live stats, snapshots
Each directory has its own README covering what it does and how to run it.
diff --git a/vm-manager/Button.qml b/vm-manager/Button.qml
new file mode 100644
index 0000000..d6ca78f
--- /dev/null
+++ b/vm-manager/Button.qml
@@ -0,0 +1,45 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+
+Rectangle {
+ id: btn
+ property alias text: label.text
+ property bool danger: false
+ property bool enabled: true
+ signal clicked
+
+ implicitWidth: label.implicitWidth + 22
+ implicitHeight: label.implicitHeight + 12
+ radius: 7
+ opacity: enabled ? 1 : 0.4
+ color: area.containsMouse && enabled
+ ? Qt.alpha(danger ? Theme.red : Theme.accent, 0.25)
+ : Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(danger ? Theme.red : Theme.text, 0.2)
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: danger ? Theme.red : Theme.text
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (btn.enabled) btn.clicked()
+ }
+}
diff --git a/vm-manager/README.md b/vm-manager/README.md
new file mode 100644
index 0000000..e11b7f2
--- /dev/null
+++ b/vm-manager/README.md
@@ -0,0 +1,97 @@
+# 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` is the copy from `volume-osd` with three colours added for state
+dots (green running, yellow transitional, red crashed). The accent still comes
+from `~/.cache/wal/udt-accent.rasi` and still tracks the wallpaper.
+
+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,
+ })
diff --git a/vm-manager/Stat.qml b/vm-manager/Stat.qml
new file mode 100644
index 0000000..6337425
--- /dev/null
+++ b/vm-manager/Stat.qml
@@ -0,0 +1,44 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+
+// One labelled stat with an optional bar. A negative fraction means the
+// figure is unavailable, so the bar is left out rather than drawn at zero.
+Column {
+ required property string label
+ required property string value
+ property real fraction: -1
+
+ spacing: 4
+
+ Text {
+ text: label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ Text {
+ text: value
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ }
+ Rectangle {
+ visible: fraction >= 0
+ width: 150; height: 4; radius: 2
+ color: Qt.alpha(Theme.text, 0.12)
+ Rectangle {
+ width: parent.width * Math.max(0, Math.min(1, fraction))
+ height: parent.height; radius: parent.radius
+ color: Theme.accent
+ Behavior on width { NumberAnimation { duration: 200 } }
+ }
+ }
+}
diff --git a/vm-manager/Theme.qml b/vm-manager/Theme.qml
new file mode 100644
index 0000000..714e289
--- /dev/null
+++ b/vm-manager/Theme.qml
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ // Catppuccin Macchiato. Fixed, same base as unified-desktop-theme.
+ readonly property color base: "#24273a"
+ readonly property color surface: "#363a4f"
+ readonly property color text: "#cad3f5"
+ readonly property color subtext: "#a5adcb"
+ readonly property color red: "#ed8796"
+ readonly property color green: "#a6da95"
+ readonly property color yellow: "#eed49f"
+ readonly property color overlay: "#6e738d"
+
+ // Tracks the wallpaper, like rofi and dunst do. Lavender until read.
+ property color accent: "#b7bdf8"
+
+ readonly property string fontFamily: "Noto Sans"
+ readonly property int fontSize: 16
+
+ // udt-accent writes one line: `* { accent: #rrggbbaa; }`
+ FileView {
+ path: `${Quickshell.env("HOME")}/.cache/wal/udt-accent.rasi`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: {
+ const m = text().match(/accent:\s*(#[0-9a-fA-F]{6})/);
+ if (m) root.accent = m[1];
+ }
+ }
+}
diff --git a/vm-manager/Virsh.qml b/vm-manager/Virsh.qml
new file mode 100644
index 0000000..309781c
--- /dev/null
+++ b/vm-manager/Virsh.qml
@@ -0,0 +1,308 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+// Everything that talks to libvirt. Shelling out to virsh rather than binding
+// libvirt: the commands are the same ones the old rofi script used, and this
+// adds no dependency.
+Singleton {
+ id: root
+
+ // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent }
+ property var vms: ({})
+ property var names: []
+ property var snapshots: ({}) // name -> [{ name, created, state, current }]
+
+ // Polling only runs while the panel is open; the event stream always does.
+ property bool sampling: false
+
+ signal actionFailed(string vm, string action, string message)
+
+ function _vm(name) {
+ return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1,
+ fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false };
+ }
+
+ function _set(name, fields) {
+ const next = Object.assign({}, vms);
+ next[name] = Object.assign({}, _vm(name), fields);
+ vms = next;
+ }
+
+ // --- discovery -------------------------------------------------------
+
+ Process {
+ id: listProc
+ command: ["virsh", "list", "--all", "--name"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length);
+ root.names = found;
+ for (const n of found) if (!(n in root.vms)) root._set(n, {});
+ root.refresh();
+ }
+ }
+ }
+
+ // --- state + stats ---------------------------------------------------
+
+ // cpu.time is cumulative nanoseconds, so a percentage needs two samples.
+ property var _lastCpu: ({})
+
+ Process {
+ id: statsProc
+ command: ["virsh", "domstats", "--state", "--cpu-total", "--balloon", "--vcpu"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const now = Date.now();
+ let vm = null;
+ for (const line of text.split("\n")) {
+ const dom = line.match(/^Domain: '(.+)'$/);
+ if (dom) { vm = dom[1]; continue; }
+ if (!vm) continue;
+ const kv = line.trim().match(/^([\w.]+)=(.+)$/);
+ if (!kv) continue;
+ const [, key, val] = kv;
+
+ if (key === "state.state") {
+ root._set(vm, { state: root._stateName(parseInt(val)) });
+ } else if (key === "vcpu.current") {
+ root._set(vm, { vcpus: parseInt(val) });
+ } else if (key === "cpu.time") {
+ const ns = parseInt(val);
+ const prev = root._lastCpu[vm];
+ if (prev && now > prev.at) {
+ const vcpus = root._vm(vm).vcpus || 1;
+ const pct = (ns - prev.ns) / ((now - prev.at) * 1e6) / vcpus * 100;
+ root._set(vm, { cpu: Math.max(0, Math.min(100, pct)) });
+ }
+ const c = Object.assign({}, root._lastCpu);
+ c[vm] = { ns: ns, at: now };
+ root._lastCpu = c;
+ }
+ }
+ root._pollAgents();
+ }
+ }
+ }
+
+ function _stateName(n) {
+ // libvirt VIR_DOMAIN_* enum
+ return ({ 1: "running", 2: "blocked", 3: "paused", 4: "shutting down",
+ 5: "shut off", 6: "crashed", 7: "suspended" })[n] ?? "unknown";
+ }
+
+ // --- guest agent -----------------------------------------------------
+ //
+ // Memory, filesystem usage and IP all come from qemu-guest-agent. It is
+ // not up while the VM boots, and a VM may not have it at all, so every
+ // one of these failing is ordinary: the rows show a dash rather than
+ // falling back to a host-side number that means something different.
+
+ property var _agentQueue: []
+
+ function _pollAgents() {
+ _agentQueue = names.filter(n => _vm(n).state === "running");
+ _nextAgent();
+ }
+
+ function _nextAgent() {
+ if (_agentQueue.length === 0) return;
+ const vm = _agentQueue[0];
+ _agentQueue = _agentQueue.slice(1);
+ memProc.vm = vm;
+ memProc.command = ["virsh", "dommemstat", vm];
+ memProc.running = true;
+ }
+
+ Process {
+ id: memProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const get = k => {
+ const m = text.match(new RegExp("^" + k + " (\\d+)$", "m"));
+ return m ? parseInt(m[1]) * 1024 : -1;
+ };
+ const total = get("actual"), usable = get("usable");
+ if (total > 0 && usable > 0) {
+ memProc.vmSet({ memUsed: total - usable, memTotal: total, agent: true });
+ } else {
+ memProc.vmSet({ memUsed: -1, memTotal: total, agent: false });
+ }
+ fsProc.vm = memProc.vm;
+ fsProc.command = ["virsh", "qemu-agent-command", memProc.vm,
+ '{"execute":"guest-get-fsinfo"}'];
+ fsProc.running = true;
+ }
+ }
+ function vmSet(f) { root._set(vm, f); }
+ onExited: code => { if (code !== 0) root._set(vm, { agent: false, memUsed: -1 }); }
+ }
+
+ Process {
+ id: fsProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const fs = JSON.parse(text).return;
+ // The root filesystem is the one worth showing; the ESP is noise.
+ const r = fs.find(f => f.mountpoint === "/") ?? fs[0];
+ if (r && r["total-bytes"] > 0)
+ root._set(fsProc.vm, { fsUsed: r["used-bytes"], fsTotal: r["total-bytes"] });
+ } catch (e) {
+ root._set(fsProc.vm, { fsUsed: -1, fsTotal: -1 });
+ }
+ ipProc.vm = fsProc.vm;
+ ipProc.command = ["virsh", "domifaddr", fsProc.vm, "--source", "agent"];
+ ipProc.running = true;
+ }
+ }
+ onExited: code => { if (code !== 0) root._set(vm, { fsUsed: -1, fsTotal: -1 }); }
+ }
+
+ Process {
+ id: ipProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ // Skip loopback and link-local; the first real v4 address wins.
+ const m = text.match(/\s(\d+\.\d+\.\d+\.\d+)\/\d+/g) ?? [];
+ const ip = m.map(s => s.trim().split("/")[0])
+ .find(a => !a.startsWith("127.")) ?? "";
+ root._set(ipProc.vm, { ip: ip });
+ root._nextAgent();
+ }
+ }
+ onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } }
+ }
+
+ // --- snapshots -------------------------------------------------------
+
+ Process {
+ id: snapProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const rows = [];
+ for (const line of text.split("\n").slice(2)) {
+ // Name, then creation date and time, then state.
+ const m = line.trim().match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[^\s]*\s*\S*\s+(\S+)/);
+ if (m) rows.push({ name: m[1], created: m[2], state: m[3] });
+ }
+ const next = Object.assign({}, root.snapshots);
+ next[snapProc.vm] = rows;
+ root.snapshots = next;
+ }
+ }
+ }
+
+ function loadSnapshots(vm) {
+ snapProc.vm = vm;
+ snapProc.command = ["virsh", "snapshot-list", vm];
+ snapProc.running = true;
+ }
+
+ // --- actions ---------------------------------------------------------
+
+ readonly property var _cmds: ({
+ start: v => ["virsh", "start", v],
+ shutdown: v => ["virsh", "shutdown", "--mode", "acpi", "--domain", v],
+ reboot: v => ["virsh", "reboot", v],
+ reset: v => ["virsh", "reset", v],
+ destroy: v => ["virsh", "destroy", v],
+ suspend: v => ["virsh", "suspend", v],
+ resume: v => ["virsh", "resume", v],
+ })
+
+ function act(vm, action) {
+ actProc.vm = vm; actProc.action = action;
+ actProc.command = _cmds[action](vm);
+ actProc.running = true;
+ }
+
+ function snapshotCreate(vm) {
+ const ts = Qt.formatDateTime(new Date(), "ddMMyyyy_HHmmss");
+ actProc.vm = vm; actProc.action = "snapshot";
+ actProc.command = ["virsh", "snapshot-create-as", "--domain", vm,
+ "--name", `${vm}_${ts}`];
+ actProc.running = true;
+ }
+
+ function snapshotRevert(vm, snap) {
+ actProc.vm = vm; actProc.action = "revert";
+ actProc.command = ["virsh", "snapshot-revert", vm, snap];
+ actProc.running = true;
+ }
+
+ function snapshotDelete(vm, snap) {
+ actProc.vm = vm; actProc.action = "snapshot delete";
+ actProc.command = ["virsh", "snapshot-delete", vm, snap];
+ actProc.running = true;
+ }
+
+ // Undefine with --remove-all-storage erases the disk image. The panel
+ // requires the VM's name typed before it will call this.
+ function deleteVm(vm) {
+ actProc.vm = vm; actProc.action = "delete";
+ actProc.command = ["sh", "-c",
+ `virsh destroy ${JSON.stringify(vm)} 2>/dev/null; ` +
+ `virsh undefine ${JSON.stringify(vm)} --remove-all-storage`];
+ actProc.running = true;
+ }
+
+ Process {
+ id: actProc
+ property string vm: ""
+ property string action: ""
+ stderr: StdioCollector { id: actErr }
+ onExited: code => {
+ if (code !== 0)
+ root.actionFailed(actProc.vm, actProc.action,
+ actErr.text.trim() || `exited ${code}`);
+ root.refreshList();
+ if (root.snapshots[actProc.vm]) root.loadSnapshots(actProc.vm);
+ }
+ }
+
+ // --- refresh ---------------------------------------------------------
+
+ function refresh() { statsProc.running = true; }
+ function refreshList() { listProc.running = true; }
+
+ Timer {
+ interval: 2000
+ running: root.sampling
+ repeat: true
+ onTriggered: root.refresh()
+ }
+
+ // libvirt pushes lifecycle changes, so a VM started from virt-manager or
+ // the CLI updates the panel too. This runs whether or not it is open,
+ // which is what makes the panel correct the moment it is shown.
+ Process {
+ running: true
+ command: ["virsh", "event", "--all", "--loop"]
+ stdout: SplitParser {
+ onRead: line => {
+ if (/event '(lifecycle|agent-lifecycle)'/.test(line)) root.refreshList();
+ }
+ }
+ }
+
+ Component.onCompleted: refreshList()
+}
diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml
new file mode 100644
index 0000000..7aecaab
--- /dev/null
+++ b/vm-manager/VmPanel.qml
@@ -0,0 +1,416 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ // The screen the drawer opens on. Falls back to the focused one when
+ // this monitor is not connected, so the panel is never invisible.
+ property string monitor: "DP-3"
+
+ property bool open: false
+ property string selected: ""
+
+ // A pending destructive action, shown as a confirm step instead of the
+ // action list: { kind, vm, snap }. Null when nothing is being confirmed.
+ property var confirming: null
+ property string typed: ""
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function toggle() { root.open = !root.open; }
+ function close() { root.open = false; root.confirming = null; root.typed = ""; }
+
+ onOpenChanged: {
+ Virsh.sampling = open;
+ confirming = null;
+ typed = "";
+ if (open) {
+ Virsh.refreshList();
+ if (!selected && Virsh.names.length) selected = Virsh.names[0];
+ if (selected) Virsh.loadSnapshots(selected);
+ }
+ }
+
+ onSelectedChanged: if (selected) Virsh.loadSnapshots(selected)
+
+ function fmtBytes(b) {
+ if (b < 0) return "—";
+ const g = b / (1024 * 1024 * 1024);
+ return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB";
+ }
+
+ function stateColor(s) {
+ if (s === "running") return Theme.green;
+ if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow;
+ if (s === "crashed") return Theme.red;
+ return Theme.overlay;
+ }
+
+ // Which verbs make sense in the current state, mirroring the states the
+ // old rofi script switched on.
+ function actionsFor(s) {
+ if (s === "running")
+ return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
+ ["reset", "Reset"], ["destroy", "Force stop"]];
+ if (s === "paused" || s === "suspended")
+ return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
+ return [["start", "Start"]];
+ }
+
+ function isDestructive(a) { return a === "reset" || a === "destroy"; }
+
+ function run(vm, action) {
+ if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" };
+ else Virsh.act(vm, action);
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ id: win
+ screen: root.screenObj
+
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-vm-manager"
+ // The panel needs keys: Escape to close, typing to confirm a delete.
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ // Dim the screen behind: the secondary monitor usually has an
+ // editor or a chat window on it, and this says the drawer has focus.
+ Rectangle {
+ anchors.fill: parent
+ color: "#000000"
+ opacity: 0.45
+ MouseArea { anchors.fill: parent; onClicked: root.close() }
+ }
+
+ Keys.onEscapePressed: root.confirming ? root.confirming = null : root.close()
+
+ Rectangle {
+ id: drawer
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: Math.min(content.implicitHeight + 40, win.height - 60)
+ color: Qt.alpha(Theme.base, 0.72)
+ bottomLeftRadius: 14
+ bottomRightRadius: 14
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ // Clicks on the drawer must not fall through to the dimmer.
+ MouseArea { anchors.fill: parent }
+
+ Column {
+ id: content
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 }
+ spacing: 16
+
+ Row {
+ spacing: 10
+ Text {
+ text: "Virtual machines"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Esc to close"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ }
+
+ // One row per VM, so several VMs stay readable at a glance.
+ Repeater {
+ model: Virsh.names
+
+ Rectangle {
+ required property string modelData
+ readonly property var vm: Virsh.vms[modelData] ?? ({})
+ readonly property bool isSel: root.selected === modelData
+
+ width: content.width
+ implicitHeight: vmCol.implicitHeight + 24
+ radius: 10
+ color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent"
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.selected = modelData
+ }
+
+ Column {
+ id: vmCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 10
+
+ Row {
+ spacing: 10
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 9; height: 9; radius: 5
+ color: root.stateColor(vm.state ?? "")
+ }
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: vm.state ?? ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: (vm.vcpus ?? 0) + " vCPU"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: vm.state === "running" && !(vm.agent ?? false)
+ text: "agent starting"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ }
+
+ // Stats only mean anything while the VM runs.
+ Row {
+ visible: vm.state === "running"
+ spacing: 24
+
+ Stat {
+ label: "CPU"
+ value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%"
+ fraction: (vm.cpu ?? 0) / 100
+ }
+ Stat {
+ label: "RAM"
+ value: (vm.memUsed ?? -1) < 0 ? "—"
+ : root.fmtBytes(vm.memUsed) + " / " + root.fmtBytes(vm.memTotal)
+ fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal
+ }
+ Stat {
+ label: "Disk"
+ value: (vm.fsUsed ?? -1) < 0 ? "—"
+ : root.fmtBytes(vm.fsUsed) + " / " + root.fmtBytes(vm.fsTotal)
+ fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal
+ }
+ Stat {
+ label: "Address"
+ value: (vm.ip ?? "") === "" ? "—" : vm.ip
+ fraction: -1
+ }
+ }
+
+ // Actions and snapshots, for the selected VM only.
+ Loader {
+ active: isSel
+ width: parent.width
+ sourceComponent: detail
+ property string vmName: modelData
+ property string vmState: vm.state ?? ""
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: detail
+
+ Column {
+ spacing: 12
+
+ readonly property string vmName: parent.vmName
+ readonly property string vmState: parent.vmState
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
+
+ // Confirm step replaces the buttons, so the action cannot be
+ // clicked again while it is being confirmed.
+ Loader {
+ active: root.confirming !== null && root.confirming.vm === vmName
+ width: parent.width
+ sourceComponent: confirmUi
+ }
+
+ Flow {
+ visible: !(root.confirming !== null && root.confirming.vm === vmName)
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: root.actionsFor(vmState)
+ Button {
+ required property var modelData
+ text: modelData[1]
+ danger: root.isDestructive(modelData[0])
+ onClicked: root.run(vmName, modelData[0])
+ }
+ }
+ Button {
+ text: "Snapshot"
+ onClicked: Virsh.snapshotCreate(vmName)
+ }
+ Button {
+ text: "Delete VM"
+ danger: true
+ onClicked: root.confirming = { kind: "delete", vm: vmName, snap: "" }
+ }
+ }
+
+ Text {
+ visible: (Virsh.snapshots[vmName] ?? []).length > 0
+ text: "Snapshots"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: Virsh.snapshots[vmName] ?? []
+
+ Row {
+ required property var modelData
+ width: parent.width
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 260
+ elide: Text.ElideRight
+ text: modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 150
+ text: modelData.created
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 70
+ text: modelData.state
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Button {
+ text: "Revert"
+ danger: true
+ onClicked: root.confirming = { kind: "revert", vm: vmName, snap: modelData.name }
+ }
+ Button {
+ text: "Delete"
+ danger: true
+ onClicked: root.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: confirmUi
+
+ Column {
+ spacing: 10
+ readonly property var c: root.confirming
+ // Deleting a VM erases its disk image, so that one asks for the
+ // name to be typed. The rest are recoverable enough for a click.
+ readonly property bool needsTyping: c && c.kind === "delete"
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.red
+ text: {
+ if (!c) return "";
+ if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`;
+ if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
+ if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
+ if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
+ if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
+ return "";
+ }
+ }
+
+ TextInput {
+ id: nameField
+ visible: needsTyping
+ width: 260
+ text: root.typed
+ onTextChanged: root.typed = text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ focus: needsTyping
+ Component.onCompleted: if (needsTyping) forceActiveFocus()
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -6
+ z: -1
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ Text {
+ visible: !nameField.text
+ text: "type the VM name"
+ font: nameField.font
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Confirm"
+ danger: true
+ enabled: !needsTyping || root.typed === c.vm
+ onClicked: {
+ if (c.kind === "delete") Virsh.deleteVm(c.vm);
+ else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap);
+ else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap);
+ else Virsh.act(c.vm, c.kind);
+ root.confirming = null;
+ root.typed = "";
+ }
+ }
+ Button {
+ text: "Cancel"
+ onClicked: { root.confirming = null; root.typed = ""; }
+ }
+ }
+ }
+ }
+}
diff --git a/vm-manager/shell.qml b/vm-manager/shell.qml
new file mode 100644
index 0000000..6bd926b
--- /dev/null
+++ b/vm-manager/shell.qml
@@ -0,0 +1,41 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+ShellRoot {
+ VmPanel { id: panel }
+
+ // Bound to a key in Hyprland: `qs -p <this dir> ipc call panel toggle`.
+ IpcHandler {
+ target: "panel"
+ function toggle() { panel.toggle(); }
+ function show() { panel.open = true; }
+ function hide() { panel.close(); }
+ }
+
+ // An action that fails (libvirt refusing, a disk in use) has to say so:
+ // the panel is the only feedback, since virsh output goes nowhere here.
+ Process {
+ id: notify
+ property string body: ""
+ }
+ Connections {
+ target: Virsh
+ function onActionFailed(vm, action, message) {
+ notify.command = ["notify-send", "--app-name=vm-manager", "--urgency=critical",
+ "--icon=error", `${action} failed: ${vm}`, message];
+ notify.running = true;
+ }
+ }
+}