aboutsummaryrefslogtreecommitdiffstats
path: root/vm-manager
diff options
context:
space:
mode:
Diffstat (limited to 'vm-manager')
-rw-r--r--vm-manager/Button.qml45
-rw-r--r--vm-manager/README.md113
-rw-r--r--vm-manager/Stat.qml44
l---------vm-manager/Theme.qml1
-rw-r--r--vm-manager/Virsh.qml308
-rw-r--r--vm-manager/VmPanel.qml449
-rw-r--r--vm-manager/shell.qml41
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;
- }
- }
-}