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 /desktop/modules | |
| 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.
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/vm/README.md | 125 | ||||
| -rw-r--r-- | desktop/modules/vm/Stat.qml | 45 | ||||
| -rw-r--r-- | desktop/modules/vm/Virsh.qml | 349 | ||||
| -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 |
6 files changed, 973 insertions, 0 deletions
diff --git a/desktop/modules/vm/README.md b/desktop/modules/vm/README.md new file mode 100644 index 0000000..bcf12ac --- /dev/null +++ b/desktop/modules/vm/README.md @@ -0,0 +1,125 @@ +# vm-manager + +A drawer for libvirt virtual machines: state, live statistics, the actions +that make sense in the current state, and the snapshot list. It replaces the +menu half of `rofi-qemu.sh`, which could only ever show a list of strings. + + ┌─ buildsystem ───────────────── ● running ─┐ + │ CPU 4% RAM 0.7/16 GB Disk 60/130 GB │ + │ 172.16.34.20 │ + │ [Shutdown] [Reboot] [Suspend] [Reset] │ + │ snapshots │ + │ working 2026-03-06 10:27 │ + └───────────────────────────────────────────┘ + +## Running it + + qs -p . + +`SUPER+v` toggles it, through an IPC call, so the shell has to be running +first. Both are wired in the Hyprland config: + + -- autostart.lua + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/vm-manager") + + -- keybindings.lua + hl.bind(mainMod .. " + v", hl.dsp.exec_cmd( + "qs -p ~/Programming/GIT/quickshell/vm-manager ipc call panel toggle")) + +Write those two paths out in full in the real config: `exec_cmd` runs the +command directly, with no shell to expand `~`. + +The drawer opens on the monitor named by the `monitor` property at the top of +`VmPanel.qml`, defaulting to `DP-3`, and falls back to the first screen when +that one is not connected. It dims the rest of that monitor, because the +secondary screen usually has a real window on it, and takes keyboard focus so +Escape closes it and a delete can be confirmed by typing. + +## Where the numbers come from + +Everything shells out to `virsh`, the same commands the rofi script used. No +libvirt bindings, no new dependency. + +| Row | Source | +| ---------- | ------------------------------------------------- | +| state | `virsh domstats --state` | +| CPU | `cpu.time` sampled twice, divided by vCPU count | +| RAM | `virsh dommemstat`: `actual - usable` | +| Disk | guest agent `guest-get-fsinfo`, the `/` mount | +| address | `virsh domifaddr --source agent` | +| snapshots | `virsh snapshot-list` | + +Three of those need **qemu-guest-agent running inside the guest**. It is not +up for the first few seconds after boot, and another VM might not have it at +all, so those rows show `—` and the header says `agent starting` rather than +substituting a host-side figure. That distinction matters: libvirt's own +`balloon.current` is memory *allocated* to the VM, which on a VM that has +booted reads as 100% forever, and `block.0.allocation` is qcow2 growth on the +host, not usage inside the guest. Showing either in place of the real number +would be quietly wrong, so they are not used as a fallback. + +## Live without polling + +`virsh event --all --loop` streams lifecycle changes, and that process runs +for the whole session, panel open or not. Starting a VM from `virsh` or +virt-manager updates the drawer, and opening it shows current state rather +than whatever was true last time. + +Statistics do need sampling, on a 2 second timer, but only while the drawer +is open: `Virsh.sampling` follows the panel's `open`. A closed panel costs one +idle process waiting on an event socket. + +## Two things that bite + +**A config with no visible window exits.** The drawer is closed most of the +time, so the shell holds itself open with a 1x1 transparent window with an +empty mask, which is click-through and draws nothing. Without it the shell +loads, reports no error and quits, and since `SUPER+v` reaches it over IPC, +the key then has nothing to talk to and silently does nothing. + +**Key events reach an item, not a window.** Setting `keyboardFocus` on the +layer shell is necessary but not sufficient: `Keys.onEscapePressed` on the +PanelWindow itself never fires, because no item inside holds focus. A filled +`Item` with `focus: true` catches it, and takes focus back when the TextInput +in a delete confirmation gives it up. + +## Destructive actions + +`Discard saved state` appears on a shut-off VM only when one actually exists, +and runs `virsh managedsave-remove`. A VM that was saved rather than shut down +restores that memory image on the next `start`, and when the image cannot be +restored the start fails every time with a QEMU `migrate-incoming` error while +the panel shows an ordinary `shut off`. Discarding it deletes the memory image +and nothing else, so the next start is a cold boot and the disk is untouched. +That is why it takes a confirmation but not a typed name. + +Detection is `virsh dominfo`, grepped for `Managed save: yes`, once per VM on +every list refresh. `domstats` does not carry it and `virsh list --name` drops +the column that would. + +`Reset`, `Force stop`, snapshot `Revert` and snapshot `Delete` each take one +confirmation click. `Delete VM` requires the machine's name to be typed, +because it runs `virsh undefine --remove-all-storage`, which erases the disk +image with no undo. The rofi script ran exactly that from a single menu +selection with no confirmation at all. + +Failures are reported with `notify-send`, since a `virsh` error otherwise has +nowhere to go: the process output is not attached to a terminal. + +## Theme + +`Theme.qml` carries only fallbacks. The palette comes from +`~/.cache/wal/udt-palette.qml`, generated by `udt-accent` from +unified-desktop-theme's `palette.rasi`, and is watched: editing the palette +there recolours a running drawer without restarting it. State dots use the +green, yellow and red from that palette. + +The frosting is Hyprland's, matched on this window's namespace: + + hl.layer_rule({ + name = "blur-vm-manager", + match = { namespace = "^(quickshell-vm-manager)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) diff --git a/desktop/modules/vm/Stat.qml b/desktop/modules/vm/Stat.qml new file mode 100644 index 0000000..f357edf --- /dev/null +++ b/desktop/modules/vm/Stat.qml @@ -0,0 +1,45 @@ +// 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 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. +Column { + required property string label + required property string value + property real fraction: -1 + + spacing: 4 + + Text { + text: label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + Text { + text: value + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.text + } + Rectangle { + visible: fraction >= 0 + width: 150; height: 4; radius: 2 + color: Qt.alpha(Theme.text, 0.12) + Rectangle { + width: parent.width * Math.max(0, Math.min(1, fraction)) + height: parent.height; radius: parent.radius + color: Theme.accent + Behavior on width { NumberAnimation { duration: 200 } } + } + } +} diff --git a/desktop/modules/vm/Virsh.qml b/desktop/modules/vm/Virsh.qml new file mode 100644 index 0000000..14bd368 --- /dev/null +++ b/desktop/modules/vm/Virsh.qml @@ -0,0 +1,349 @@ +// 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. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// Everything that talks to libvirt. Shelling out to virsh rather than binding +// libvirt: the commands are the same ones the old rofi script used, and this +// adds no dependency. +Singleton { + id: root + + // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent, saved } + property var vms: ({}) + property var names: [] + property var snapshots: ({}) // name -> [{ name, created, state, current }] + + // Polling only runs while the panel is open; the event stream always does. + property bool sampling: false + + signal actionFailed(string vm, string action, string message) + + function _vm(name) { + return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1, + fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false, + saved: false }; + } + + function _set(name, fields) { + const next = Object.assign({}, vms); + next[name] = Object.assign({}, _vm(name), fields); + vms = next; + } + + // --- discovery ------------------------------------------------------- + + Process { + id: listProc + command: ["virsh", "list", "--all", "--name"] + stdout: StdioCollector { + onStreamFinished: { + const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length); + root.names = found; + for (const n of found) if (!(n in root.vms)) root._set(n, {}); + root._pollSaved(); + root.refresh(); + } + } + } + + // --- state + stats --------------------------------------------------- + + // cpu.time is cumulative nanoseconds, so a percentage needs two samples. + property var _lastCpu: ({}) + + Process { + id: statsProc + command: ["virsh", "domstats", "--state", "--cpu-total", "--balloon", "--vcpu"] + stdout: StdioCollector { + onStreamFinished: { + const now = Date.now(); + let vm = null; + for (const line of text.split("\n")) { + const dom = line.match(/^Domain: '(.+)'$/); + if (dom) { vm = dom[1]; continue; } + if (!vm) continue; + const kv = line.trim().match(/^([\w.]+)=(.+)$/); + if (!kv) continue; + const [, key, val] = kv; + + if (key === "state.state") { + root._set(vm, { state: root._stateName(parseInt(val)) }); + } else if (key === "vcpu.current") { + root._set(vm, { vcpus: parseInt(val) }); + } else if (key === "cpu.time") { + const ns = parseInt(val); + const prev = root._lastCpu[vm]; + if (prev && now > prev.at) { + const vcpus = root._vm(vm).vcpus || 1; + const pct = (ns - prev.ns) / ((now - prev.at) * 1e6) / vcpus * 100; + root._set(vm, { cpu: Math.max(0, Math.min(100, pct)) }); + } + const c = Object.assign({}, root._lastCpu); + c[vm] = { ns: ns, at: now }; + root._lastCpu = c; + } + } + root._pollAgents(); + } + } + } + + function _stateName(n) { + // libvirt VIR_DOMAIN_* enum + return ({ 1: "running", 2: "blocked", 3: "paused", 4: "shutting down", + 5: "shut off", 6: "crashed", 7: "suspended" })[n] ?? "unknown"; + } + + // --- guest agent ----------------------------------------------------- + // + // Memory, filesystem usage and IP all come from qemu-guest-agent. It is + // not up while the VM boots, and a VM may not have it at all, so every + // one of these failing is ordinary: the rows show a dash rather than + // falling back to a host-side number that means something different. + + property var _agentQueue: [] + + function _pollAgents() { + _agentQueue = names.filter(n => _vm(n).state === "running"); + _nextAgent(); + } + + function _nextAgent() { + if (_agentQueue.length === 0) return; + const vm = _agentQueue[0]; + _agentQueue = _agentQueue.slice(1); + memProc.vm = vm; + memProc.command = ["virsh", "dommemstat", vm]; + memProc.running = true; + } + + Process { + id: memProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + const get = k => { + const m = text.match(new RegExp("^" + k + " (\\d+)$", "m")); + return m ? parseInt(m[1]) * 1024 : -1; + }; + const total = get("actual"), usable = get("usable"); + if (total > 0 && usable > 0) { + memProc.vmSet({ memUsed: total - usable, memTotal: total, agent: true }); + } else { + memProc.vmSet({ memUsed: -1, memTotal: total, agent: false }); + } + fsProc.vm = memProc.vm; + fsProc.command = ["virsh", "qemu-agent-command", memProc.vm, + '{"execute":"guest-get-fsinfo"}']; + fsProc.running = true; + } + } + function vmSet(f) { root._set(vm, f); } + onExited: code => { if (code !== 0) root._set(vm, { agent: false, memUsed: -1 }); } + } + + Process { + id: fsProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + try { + const fs = JSON.parse(text).return; + // The root filesystem is the one worth showing; the ESP is noise. + const r = fs.find(f => f.mountpoint === "/") ?? fs[0]; + if (r && r["total-bytes"] > 0) + root._set(fsProc.vm, { fsUsed: r["used-bytes"], fsTotal: r["total-bytes"] }); + } catch (e) { + root._set(fsProc.vm, { fsUsed: -1, fsTotal: -1 }); + } + ipProc.vm = fsProc.vm; + ipProc.command = ["virsh", "domifaddr", fsProc.vm, "--source", "agent"]; + ipProc.running = true; + } + } + onExited: code => { if (code !== 0) root._set(vm, { fsUsed: -1, fsTotal: -1 }); } + } + + Process { + id: ipProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + // Skip loopback and link-local; the first real v4 address wins. + const m = text.match(/\s(\d+\.\d+\.\d+\.\d+)\/\d+/g) ?? []; + const ip = m.map(s => s.trim().split("/")[0]) + .find(a => !a.startsWith("127.")) ?? ""; + root._set(ipProc.vm, { ip: ip }); + root._nextAgent(); + } + } + onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } } + } + + // --- managed save ---------------------------------------------------- + // + // A shut-off VM can still carry a saved memory image, and `virsh start` + // then restores it rather than booting. When that image cannot be + // restored the start fails every time with a QEMU migrate-incoming + // error, and nothing in the panel said why. `domstats` does not report + // it: the shut-off reason reads "failed", from the failed start, not + // from the save. `dominfo` is the only cheap source, so it is polled per + // VM the way the agent rows are. + + property var _savedQueue: [] + + function _pollSaved() { + _savedQueue = names.slice(); + _nextSaved(); + } + + function _nextSaved() { + if (_savedQueue.length === 0) return; + const vm = _savedQueue[0]; + _savedQueue = _savedQueue.slice(1); + savedProc.vm = vm; + savedProc.command = ["virsh", "dominfo", vm]; + savedProc.running = true; + } + + Process { + id: savedProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + root._set(savedProc.vm, { saved: /^Managed save:\s+yes$/m.test(text) }); + root._nextSaved(); + } + } + onExited: code => { if (code !== 0) { root._set(vm, { saved: false }); root._nextSaved(); } } + } + + // --- snapshots ------------------------------------------------------- + + Process { + id: snapProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + const rows = []; + for (const line of text.split("\n").slice(2)) { + // Name, then creation date and time, then state. + const m = line.trim().match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[^\s]*\s*\S*\s+(\S+)/); + if (m) rows.push({ name: m[1], created: m[2], state: m[3] }); + } + const next = Object.assign({}, root.snapshots); + next[snapProc.vm] = rows; + root.snapshots = next; + } + } + } + + function loadSnapshots(vm) { + snapProc.vm = vm; + snapProc.command = ["virsh", "snapshot-list", vm]; + snapProc.running = true; + } + + // --- actions --------------------------------------------------------- + + readonly property var _cmds: ({ + start: v => ["virsh", "start", v], + shutdown: v => ["virsh", "shutdown", "--mode", "acpi", "--domain", v], + reboot: v => ["virsh", "reboot", v], + reset: v => ["virsh", "reset", v], + destroy: v => ["virsh", "destroy", v], + suspend: v => ["virsh", "suspend", v], + resume: v => ["virsh", "resume", v], + discardsave: v => ["virsh", "managedsave-remove", v], + }) + + function act(vm, action) { + actProc.vm = vm; actProc.action = action; + actProc.command = _cmds[action](vm); + actProc.running = true; + } + + function snapshotCreate(vm) { + const ts = Qt.formatDateTime(new Date(), "ddMMyyyy_HHmmss"); + actProc.vm = vm; actProc.action = "snapshot"; + actProc.command = ["virsh", "snapshot-create-as", "--domain", vm, + "--name", `${vm}_${ts}`]; + actProc.running = true; + } + + function snapshotRevert(vm, snap) { + actProc.vm = vm; actProc.action = "revert"; + actProc.command = ["virsh", "snapshot-revert", vm, snap]; + actProc.running = true; + } + + function snapshotDelete(vm, snap) { + actProc.vm = vm; actProc.action = "snapshot delete"; + actProc.command = ["virsh", "snapshot-delete", vm, snap]; + actProc.running = true; + } + + // Undefine with --remove-all-storage erases the disk image. The panel + // requires the VM's name typed before it will call this. + function deleteVm(vm) { + actProc.vm = vm; actProc.action = "delete"; + actProc.command = ["sh", "-c", + `virsh destroy ${JSON.stringify(vm)} 2>/dev/null; ` + + `virsh undefine ${JSON.stringify(vm)} --remove-all-storage`]; + actProc.running = true; + } + + Process { + id: actProc + property string vm: "" + property string action: "" + stderr: StdioCollector { id: actErr } + onExited: code => { + if (code !== 0) + root.actionFailed(actProc.vm, actProc.action, + actErr.text.trim() || `exited ${code}`); + root.refreshList(); + if (root.snapshots[actProc.vm]) root.loadSnapshots(actProc.vm); + } + } + + // --- refresh --------------------------------------------------------- + + function refresh() { statsProc.running = true; } + function refreshList() { listProc.running = true; } + + Timer { + interval: 2000 + running: root.sampling + repeat: true + onTriggered: root.refresh() + } + + // libvirt pushes lifecycle changes, so a VM started from virt-manager or + // the CLI updates the panel too. This runs whether or not it is open, + // which is what makes the panel correct the moment it is shown. + Process { + running: true + command: ["virsh", "event", "--all", "--loop"] + stdout: SplitParser { + onRead: line => { + if (/event '(lifecycle|agent-lifecycle)'/.test(line)) root.refreshList(); + } + } + } + + Component.onCompleted: refreshList() +} 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 + } +} |
