aboutsummaryrefslogtreecommitdiffstats
path: root/vm-manager
diff options
context:
space:
mode:
Diffstat (limited to 'vm-manager')
-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
7 files changed, 998 insertions, 0 deletions
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;
+ }
+ }
+}