aboutsummaryrefslogtreecommitdiffstats
path: root/vm-manager/VmPanel.qml
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 /vm-manager/VmPanel.qml
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
Diffstat (limited to 'vm-manager/VmPanel.qml')
-rw-r--r--vm-manager/VmPanel.qml416
1 files changed, 416 insertions, 0 deletions
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 = ""; }
+ }
+ }
+ }
+ }
+}