aboutsummaryrefslogtreecommitdiffstats
path: root/vm-manager/VmPanel.qml
diff options
context:
space:
mode:
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 = ""; }
+ }
+ }
+ }
+ }
+}