diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-14 12:32:19 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-14 12:32:19 +0200 |
| commit | 315becb78eb48be186f2370ae6c5efd3e967c12d (patch) | |
| tree | 66dad581d03ebbc4f49680099e955fe6c62612e2 | |
| parent | 350b6358e29699721c636c18f8928acb68a05ade (diff) | |
| download | quickshell-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.
| -rw-r--r-- | desktop/modules/vm/README.md (renamed from vm-manager/README.md) | 0 | ||||
| -rw-r--r-- | desktop/modules/vm/Stat.qml (renamed from vm-manager/Stat.qml) | 1 | ||||
| -rw-r--r-- | desktop/modules/vm/Virsh.qml (renamed from vm-manager/Virsh.qml) | 0 | ||||
| -rw-r--r-- | desktop/modules/vm/VmModule.qml | 51 | ||||
| -rw-r--r-- | desktop/modules/vm/VmPage.qml | 360 | ||||
| -rw-r--r-- | desktop/modules/vm/VmTile.qml | 43 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 | ||||
| -rw-r--r-- | vm-manager/Button.qml | 45 | ||||
| l--------- | vm-manager/Theme.qml | 1 | ||||
| -rw-r--r-- | vm-manager/VmPanel.qml | 457 | ||||
| -rw-r--r-- | vm-manager/shell.qml | 41 |
11 files changed, 457 insertions, 544 deletions
diff --git a/vm-manager/README.md b/desktop/modules/vm/README.md index bcf12ac..bcf12ac 100644 --- a/vm-manager/README.md +++ b/desktop/modules/vm/README.md diff --git a/vm-manager/Stat.qml b/desktop/modules/vm/Stat.qml index 6337425..f357edf 100644 --- a/vm-manager/Stat.qml +++ b/desktop/modules/vm/Stat.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import QtQuick +import "../.." // One labelled stat with an optional bar. A negative fraction means the // figure is unavailable, so the bar is left out rather than drawn at zero. diff --git a/vm-manager/Virsh.qml b/desktop/modules/vm/Virsh.qml index 14bd368..14bd368 100644 --- a/vm-manager/Virsh.qml +++ b/desktop/modules/vm/Virsh.qml diff --git a/desktop/modules/vm/VmModule.qml b/desktop/modules/vm/VmModule.qml new file mode 100644 index 0000000..2d7f731 --- /dev/null +++ b/desktop/modules/vm/VmModule.qml @@ -0,0 +1,51 @@ +// 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 Quickshell.Io +import "../.." + +// Not always active: the 2s stats poll exists only to paint a page nobody is +// looking at, so the page starts and stops it. The lifecycle event stream in +// Virsh is separate and always runs, which is what keeps the tile's dots and +// the VM list correct without polling. +Module { + id: mod + + name: "vm" + icon: "" + label: "Machines" + alwaysActive: false + + tileContent: Component { VmTile {} } + + page: Component { + Page { + title: "Virtual machines" + VmPage { width: parent.width } + } + } + + // An action that fails (libvirt refusing, a disk in use) has to say so: + // the notification is the only feedback, since virsh output goes nowhere. + property Process notifyProc: Process {} + + property Connections conn: Connections { + target: Virsh + function onActionFailed(vm, action, message) { + mod.notifyProc.command = ["notify-send", "--app-name=vm-manager", + "--urgency=critical", "--icon=error", + `${action} failed: ${vm}`, message]; + mod.notifyProc.running = false; + mod.notifyProc.running = true; + } + } +} 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 = ""; } + } + } + } + } +} diff --git a/desktop/modules/vm/VmTile.qml b/desktop/modules/vm/VmTile.qml new file mode 100644 index 0000000..a86258a --- /dev/null +++ b/desktop/modules/vm/VmTile.qml @@ -0,0 +1,43 @@ +// 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 "../.." + +// One dot per VM, green for running. The state behind these comes from the +// lifecycle event stream, which runs whether or not the page is open, so the +// dots are current without the stats poll. +Row { + spacing: 4 + + Repeater { + model: Virsh.names + + Rectangle { + required property string modelData + readonly property string state: Virsh.vms[modelData]?.state ?? "" + + anchors.verticalCenter: parent.verticalCenter + width: 7; height: 7; radius: 4 + color: state === "running" ? Theme.green + : state === "paused" || state === "suspended" ? Theme.yellow + : Theme.overlay + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: Virsh.names.length === 0 + text: "no VMs" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index d27583d..74c1ec2 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -15,6 +15,7 @@ import Quickshell.Wayland import "modules/appearance" import "modules/mail" import "modules/sound" +import "modules/vm" ShellRoot { // Quickshell exits once no window is visible, and the drawer is closed @@ -35,6 +36,7 @@ ShellRoot { SoundModule {}, MailModule {}, AppearanceModule {}, + VmModule {}, ] } diff --git a/vm-manager/Button.qml b/vm-manager/Button.qml deleted file mode 100644 index d6ca78f..0000000 --- a/vm-manager/Button.qml +++ /dev/null @@ -1,45 +0,0 @@ -// 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 - -Rectangle { - id: btn - property alias text: label.text - property bool danger: false - property bool enabled: true - signal clicked - - implicitWidth: label.implicitWidth + 22 - implicitHeight: label.implicitHeight + 12 - radius: 7 - opacity: enabled ? 1 : 0.4 - color: area.containsMouse && enabled - ? Qt.alpha(danger ? Theme.red : Theme.accent, 0.25) - : Qt.alpha(Theme.surface, 0.8) - border.width: 1 - border.color: Qt.alpha(danger ? Theme.red : Theme.text, 0.2) - - Text { - id: label - anchors.centerIn: parent - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - color: danger ? Theme.red : Theme.text - } - - MouseArea { - id: area - anchors.fill: parent - hoverEnabled: true - cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (btn.enabled) btn.clicked() - } -} diff --git a/vm-manager/Theme.qml b/vm-manager/Theme.qml deleted file mode 120000 index 3d2e40f..0000000 --- a/vm-manager/Theme.qml +++ /dev/null @@ -1 +0,0 @@ -../shared/Theme.qml
\ No newline at end of file diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml deleted file mode 100644 index 5960a1a..0000000 --- a/vm-manager/VmPanel.qml +++ /dev/null @@ -1,457 +0,0 @@ -// 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, 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)) root.confirming = { kind: action, vm: vm, snap: "" }; - else Virsh.act(vm, action); - } - - // Quickshell exits once no window is visible, and the drawer is closed - // most of the time, so a 1x1 transparent window holds the process open. - // Without it the shell quits and the keybind, which reaches it over IPC, - // has nothing left to talk to. - PanelWindow { - visible: true - implicitWidth: 1 - implicitHeight: 1 - color: "transparent" - exclusionMode: ExclusionMode.Ignore - mask: Region {} - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - } - - 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 reach an item, not the window, so something inside has to - // hold focus. The TextInput in a delete confirmation takes it when - // it appears; this takes it back whenever that is gone. - Item { - id: keyCatcher - anchors.fill: parent - focus: true - - Keys.onEscapePressed: { - if (root.confirming) root.confirming = null; - else root.close(); - } - - Connections { - target: root - function onConfirmingChanged() { - if (!root.confirming) keyCatcher.forceActiveFocus(); - } - } - } - - 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 ?? "" - 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: 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, vmSaved) - 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 === "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: 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 = ""; } - } - } - } - } -} diff --git a/vm-manager/shell.qml b/vm-manager/shell.qml deleted file mode 100644 index 6bd926b..0000000 --- a/vm-manager/shell.qml +++ /dev/null @@ -1,41 +0,0 @@ -// 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.Io -import QtQuick - -ShellRoot { - VmPanel { id: panel } - - // Bound to a key in Hyprland: `qs -p <this dir> ipc call panel toggle`. - IpcHandler { - target: "panel" - function toggle() { panel.toggle(); } - function show() { panel.open = true; } - function hide() { panel.close(); } - } - - // An action that fails (libvirt refusing, a disk in use) has to say so: - // the panel is the only feedback, since virsh output goes nowhere here. - Process { - id: notify - property string body: "" - } - Connections { - target: Virsh - function onActionFailed(vm, action, message) { - notify.command = ["notify-send", "--app-name=vm-manager", "--urgency=critical", - "--icon=error", `${action} failed: ${vm}`, message]; - notify.running = true; - } - } -} |
