diff options
Diffstat (limited to 'vm-manager/VmPanel.qml')
| -rw-r--r-- | vm-manager/VmPanel.qml | 416 |
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 = ""; } + } + } + } + } +} |
