diff options
Diffstat (limited to 'vm-manager')
| -rw-r--r-- | vm-manager/Button.qml | 45 | ||||
| -rw-r--r-- | vm-manager/README.md | 113 | ||||
| -rw-r--r-- | vm-manager/Stat.qml | 44 | ||||
| l--------- | vm-manager/Theme.qml | 1 | ||||
| -rw-r--r-- | vm-manager/Virsh.qml | 308 | ||||
| -rw-r--r-- | vm-manager/VmPanel.qml | 449 | ||||
| -rw-r--r-- | vm-manager/shell.qml | 41 |
7 files changed, 0 insertions, 1001 deletions
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/README.md b/vm-manager/README.md deleted file mode 100644 index 8d817f9..0000000 --- a/vm-manager/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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 - -`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/vm-manager/Stat.qml b/vm-manager/Stat.qml deleted file mode 100644 index 6337425..0000000 --- a/vm-manager/Stat.qml +++ /dev/null @@ -1,44 +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 - -// 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/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/Virsh.qml b/vm-manager/Virsh.qml deleted file mode 100644 index 309781c..0000000 --- a/vm-manager/Virsh.qml +++ /dev/null @@ -1,308 +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. - -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 } - 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 }; - } - - 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.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(); } } - } - - // --- 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], - }) - - 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/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml deleted file mode 100644 index cf224d6..0000000 --- a/vm-manager/VmPanel.qml +++ /dev/null @@ -1,449 +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) { - 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); - } - - // 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 ?? "" - } - } - } - } - } - } - } - } - - 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 = ""; } - } - } - } - } -} 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; - } - } -} |
