// Copyright (C) 2026 Danilo M. // // 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() // Escape cancels the pending confirmation rather than // navigating away from the page; this handler consumes the key. Keys.onEscapePressed: { page.confirming = null; page.typed = ""; } 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 = ""; } } } } } }