aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/vm/VmPage.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 12:32:19 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 12:32:19 +0200
commit315becb78eb48be186f2370ae6c5efd3e967c12d (patch)
tree66dad581d03ebbc4f49680099e955fe6c62612e2 /desktop/modules/vm/VmPage.qml
parent350b6358e29699721c636c18f8928acb68a05ade (diff)
downloadquickshell-315becb78eb48be186f2370ae6c5efd3e967c12d.tar.gz
quickshell-315becb78eb48be186f2370ae6c5efd3e967c12d.zip
feat(desktop): move vm-manager in as the vm module
Virsh.qml is unchanged; VmPanel's body becomes the page and the stat row becomes a Flow, because four stats with 150px bars do not fit a 600px drawer as one row. The page starts and stops Virsh.sampling, so the 2s poll runs only while something is looking at it; the lifecycle event stream still runs always, which is what keeps the tile's dots current.
Diffstat (limited to 'desktop/modules/vm/VmPage.qml')
-rw-r--r--desktop/modules/vm/VmPage.qml360
1 files changed, 360 insertions, 0 deletions
diff --git a/desktop/modules/vm/VmPage.qml b/desktop/modules/vm/VmPage.qml
new file mode 100644
index 0000000..5cb4dc7
--- /dev/null
+++ b/desktop/modules/vm/VmPage.qml
@@ -0,0 +1,360 @@
+// 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
+import "../.."
+
+Column {
+ id: page
+
+ 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: ""
+
+ spacing: 16
+
+ Component.onCompleted: {
+ // The stats timer only runs while this page is up, so start it here
+ // and stop it in Component.onDestruction. The lifecycle event stream
+ // in Virsh keeps running regardless, which is what makes the list
+ // correct the moment the page appears.
+ Virsh.sampling = true;
+ Virsh.refreshList();
+ if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0];
+ if (page.selected) Virsh.loadSnapshots(page.selected);
+ }
+
+ Component.onDestruction: Virsh.sampling = false
+
+ 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, saved) {
+ 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"]];
+ // Only worth offering when a saved image actually exists: without one
+ // managedsave-remove fails, and the button would be noise on every
+ // other VM.
+ if (saved)
+ return [["start", "Start"], ["discardsave", "Discard saved state"]];
+ return [["start", "Start"]];
+ }
+
+ function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
+
+ function run(vm, action) {
+ if (isDestructive(action)) page.confirming = { kind: action, vm: vm, snap: "" };
+ else Virsh.act(vm, action);
+ }
+
+ // 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: page.selected === modelData
+
+ width: page.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: page.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: page.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. The figures are
+ // guest-agent only: libvirt's balloon.current reads full
+ // forever and block.allocation is host-side qcow2 growth, so
+ // a dash is correct where the agent is silent.
+ Flow {
+ visible: vm.state === "running"
+ width: parent.width
+ spacing: 20
+
+ 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 ? "—"
+ : page.fmtBytes(vm.memUsed) + " / " + page.fmtBytes(vm.memTotal)
+ fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal
+ }
+ Stat {
+ label: "Disk"
+ value: (vm.fsUsed ?? -1) < 0 ? "—"
+ : page.fmtBytes(vm.fsUsed) + " / " + page.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 ?? ""
+ property bool vmSaved: vm.saved ?? false
+ }
+ }
+ }
+ }
+
+ Component {
+ id: detail
+
+ Column {
+ spacing: 12
+
+ readonly property string vmName: parent.vmName
+ readonly property string vmState: parent.vmState
+ readonly property bool vmSaved: parent.vmSaved
+
+ 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: page.confirming !== null && page.confirming.vm === vmName
+ width: parent.width
+ sourceComponent: confirmUi
+ }
+
+ Flow {
+ visible: !(page.confirming !== null && page.confirming.vm === vmName)
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: page.actionsFor(vmState, vmSaved)
+ Button {
+ required property var modelData
+ text: modelData[1]
+ danger: page.isDestructive(modelData[0])
+ onClicked: page.run(vmName, modelData[0])
+ }
+ }
+ Button {
+ text: "Snapshot"
+ onClicked: Virsh.snapshotCreate(vmName)
+ }
+ Button {
+ text: "Delete VM"
+ danger: true
+ onClicked: page.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] ?? []
+
+ Column {
+ required property var modelData
+ width: parent.width
+ spacing: 4
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+
+ Row {
+ width: parent.width
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.created
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.state
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Button {
+ text: "Revert"
+ danger: true
+ onClicked: page.confirming = { kind: "revert", vm: vmName, snap: modelData.name }
+ }
+ Button {
+ text: "Delete"
+ danger: true
+ onClicked: page.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: confirmUi
+
+ Column {
+ spacing: 10
+ readonly property var c: page.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 === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
+ 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: page.typed
+ onTextChanged: page.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 || page.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);
+ page.confirming = null;
+ page.typed = "";
+ }
+ }
+ Button {
+ text: "Cancel"
+ onClicked: { page.confirming = null; page.typed = ""; }
+ }
+ }
+ }
+ }
+}