aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/vm/README.md (renamed from vm-manager/README.md)0
-rw-r--r--desktop/modules/vm/Stat.qml (renamed from vm-manager/Stat.qml)1
-rw-r--r--desktop/modules/vm/Virsh.qml (renamed from vm-manager/Virsh.qml)0
-rw-r--r--desktop/modules/vm/VmModule.qml51
-rw-r--r--desktop/modules/vm/VmPage.qml360
-rw-r--r--desktop/modules/vm/VmTile.qml43
-rw-r--r--desktop/shell.qml2
-rw-r--r--vm-manager/Button.qml45
l---------vm-manager/Theme.qml1
-rw-r--r--vm-manager/VmPanel.qml457
-rw-r--r--vm-manager/shell.qml41
11 files changed, 457 insertions, 544 deletions
diff --git a/vm-manager/README.md b/desktop/modules/vm/README.md
index bcf12ac..bcf12ac 100644
--- a/vm-manager/README.md
+++ b/desktop/modules/vm/README.md
diff --git a/vm-manager/Stat.qml b/desktop/modules/vm/Stat.qml
index 6337425..f357edf 100644
--- a/vm-manager/Stat.qml
+++ b/desktop/modules/vm/Stat.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
// One labelled stat with an optional bar. A negative fraction means the
// figure is unavailable, so the bar is left out rather than drawn at zero.
diff --git a/vm-manager/Virsh.qml b/desktop/modules/vm/Virsh.qml
index 14bd368..14bd368 100644
--- a/vm-manager/Virsh.qml
+++ b/desktop/modules/vm/Virsh.qml
diff --git a/desktop/modules/vm/VmModule.qml b/desktop/modules/vm/VmModule.qml
new file mode 100644
index 0000000..2d7f731
--- /dev/null
+++ b/desktop/modules/vm/VmModule.qml
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import Quickshell.Io
+import "../.."
+
+// Not always active: the 2s stats poll exists only to paint a page nobody is
+// looking at, so the page starts and stops it. The lifecycle event stream in
+// Virsh is separate and always runs, which is what keeps the tile's dots and
+// the VM list correct without polling.
+Module {
+ id: mod
+
+ name: "vm"
+ icon: ""
+ label: "Machines"
+ alwaysActive: false
+
+ tileContent: Component { VmTile {} }
+
+ page: Component {
+ Page {
+ title: "Virtual machines"
+ VmPage { width: parent.width }
+ }
+ }
+
+ // An action that fails (libvirt refusing, a disk in use) has to say so:
+ // the notification is the only feedback, since virsh output goes nowhere.
+ property Process notifyProc: Process {}
+
+ property Connections conn: Connections {
+ target: Virsh
+ function onActionFailed(vm, action, message) {
+ mod.notifyProc.command = ["notify-send", "--app-name=vm-manager",
+ "--urgency=critical", "--icon=error",
+ `${action} failed: ${vm}`, message];
+ mod.notifyProc.running = false;
+ mod.notifyProc.running = true;
+ }
+ }
+}
diff --git a/desktop/modules/vm/VmPage.qml b/desktop/modules/vm/VmPage.qml
new file mode 100644
index 0000000..5cb4dc7
--- /dev/null
+++ b/desktop/modules/vm/VmPage.qml
@@ -0,0 +1,360 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+Column {
+ id: page
+
+ property string selected: ""
+
+ // A pending destructive action, shown as a confirm step instead of the
+ // action list: { kind, vm, snap }. Null when nothing is being confirmed.
+ property var confirming: null
+ property string typed: ""
+
+ spacing: 16
+
+ Component.onCompleted: {
+ // The stats timer only runs while this page is up, so start it here
+ // and stop it in Component.onDestruction. The lifecycle event stream
+ // in Virsh keeps running regardless, which is what makes the list
+ // correct the moment the page appears.
+ Virsh.sampling = true;
+ Virsh.refreshList();
+ if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0];
+ if (page.selected) Virsh.loadSnapshots(page.selected);
+ }
+
+ Component.onDestruction: Virsh.sampling = false
+
+ onSelectedChanged: if (selected) Virsh.loadSnapshots(selected)
+
+ function fmtBytes(b) {
+ if (b < 0) return "—";
+ const g = b / (1024 * 1024 * 1024);
+ return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB";
+ }
+
+ function stateColor(s) {
+ if (s === "running") return Theme.green;
+ if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow;
+ if (s === "crashed") return Theme.red;
+ return Theme.overlay;
+ }
+
+ // Which verbs make sense in the current state, mirroring the states the
+ // old rofi script switched on.
+ function actionsFor(s, saved) {
+ if (s === "running")
+ return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
+ ["reset", "Reset"], ["destroy", "Force stop"]];
+ if (s === "paused" || s === "suspended")
+ return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
+ // Only worth offering when a saved image actually exists: without one
+ // managedsave-remove fails, and the button would be noise on every
+ // other VM.
+ if (saved)
+ return [["start", "Start"], ["discardsave", "Discard saved state"]];
+ return [["start", "Start"]];
+ }
+
+ function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
+
+ function run(vm, action) {
+ if (isDestructive(action)) page.confirming = { kind: action, vm: vm, snap: "" };
+ else Virsh.act(vm, action);
+ }
+
+ // One row per VM, so several VMs stay readable at a glance.
+ Repeater {
+ model: Virsh.names
+
+ Rectangle {
+ required property string modelData
+ readonly property var vm: Virsh.vms[modelData] ?? ({})
+ readonly property bool isSel: page.selected === modelData
+
+ width: page.width
+ implicitHeight: vmCol.implicitHeight + 24
+ radius: 10
+ color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent"
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: page.selected = modelData
+ }
+
+ Column {
+ id: vmCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 10
+
+ Row {
+ spacing: 10
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 9; height: 9; radius: 5
+ color: page.stateColor(vm.state ?? "")
+ }
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: vm.state ?? ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: (vm.vcpus ?? 0) + " vCPU"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: vm.state === "running" && !(vm.agent ?? false)
+ text: "agent starting"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ }
+
+ // Stats only mean anything while the VM runs. The figures are
+ // guest-agent only: libvirt's balloon.current reads full
+ // forever and block.allocation is host-side qcow2 growth, so
+ // a dash is correct where the agent is silent.
+ Flow {
+ visible: vm.state === "running"
+ width: parent.width
+ spacing: 20
+
+ Stat {
+ label: "CPU"
+ value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%"
+ fraction: (vm.cpu ?? 0) / 100
+ }
+ Stat {
+ label: "RAM"
+ value: (vm.memUsed ?? -1) < 0 ? "—"
+ : page.fmtBytes(vm.memUsed) + " / " + page.fmtBytes(vm.memTotal)
+ fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal
+ }
+ Stat {
+ label: "Disk"
+ value: (vm.fsUsed ?? -1) < 0 ? "—"
+ : page.fmtBytes(vm.fsUsed) + " / " + page.fmtBytes(vm.fsTotal)
+ fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal
+ }
+ Stat {
+ label: "Address"
+ value: (vm.ip ?? "") === "" ? "—" : vm.ip
+ fraction: -1
+ }
+ }
+
+ // Actions and snapshots, for the selected VM only.
+ Loader {
+ active: isSel
+ width: parent.width
+ sourceComponent: detail
+ property string vmName: modelData
+ property string vmState: vm.state ?? ""
+ property bool vmSaved: vm.saved ?? false
+ }
+ }
+ }
+ }
+
+ Component {
+ id: detail
+
+ Column {
+ spacing: 12
+
+ readonly property string vmName: parent.vmName
+ readonly property string vmState: parent.vmState
+ readonly property bool vmSaved: parent.vmSaved
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
+
+ // Confirm step replaces the buttons, so the action cannot be
+ // clicked again while it is being confirmed.
+ Loader {
+ active: page.confirming !== null && page.confirming.vm === vmName
+ width: parent.width
+ sourceComponent: confirmUi
+ }
+
+ Flow {
+ visible: !(page.confirming !== null && page.confirming.vm === vmName)
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: page.actionsFor(vmState, vmSaved)
+ Button {
+ required property var modelData
+ text: modelData[1]
+ danger: page.isDestructive(modelData[0])
+ onClicked: page.run(vmName, modelData[0])
+ }
+ }
+ Button {
+ text: "Snapshot"
+ onClicked: Virsh.snapshotCreate(vmName)
+ }
+ Button {
+ text: "Delete VM"
+ danger: true
+ onClicked: page.confirming = { kind: "delete", vm: vmName, snap: "" }
+ }
+ }
+
+ Text {
+ visible: (Virsh.snapshots[vmName] ?? []).length > 0
+ text: "Snapshots"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: Virsh.snapshots[vmName] ?? []
+
+ Column {
+ required property var modelData
+ width: parent.width
+ spacing: 4
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+
+ Row {
+ width: parent.width
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.created
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.state
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Button {
+ text: "Revert"
+ danger: true
+ onClicked: page.confirming = { kind: "revert", vm: vmName, snap: modelData.name }
+ }
+ Button {
+ text: "Delete"
+ danger: true
+ onClicked: page.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: confirmUi
+
+ Column {
+ spacing: 10
+ readonly property var c: page.confirming
+ // Deleting a VM erases its disk image, so that one asks for the
+ // name to be typed. The rest are recoverable enough for a click.
+ readonly property bool needsTyping: c && c.kind === "delete"
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.red
+ text: {
+ if (!c) return "";
+ if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`;
+ if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
+ if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
+ if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
+ if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
+ if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
+ return "";
+ }
+ }
+
+ TextInput {
+ id: nameField
+ visible: needsTyping
+ width: 260
+ text: page.typed
+ onTextChanged: page.typed = text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ focus: needsTyping
+ Component.onCompleted: if (needsTyping) forceActiveFocus()
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -6
+ z: -1
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ Text {
+ visible: !nameField.text
+ text: "type the VM name"
+ font: nameField.font
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Confirm"
+ danger: true
+ enabled: !needsTyping || page.typed === c.vm
+ onClicked: {
+ if (c.kind === "delete") Virsh.deleteVm(c.vm);
+ else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap);
+ else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap);
+ else Virsh.act(c.vm, c.kind);
+ page.confirming = null;
+ page.typed = "";
+ }
+ }
+ Button {
+ text: "Cancel"
+ onClicked: { page.confirming = null; page.typed = ""; }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/vm/VmTile.qml b/desktop/modules/vm/VmTile.qml
new file mode 100644
index 0000000..a86258a
--- /dev/null
+++ b/desktop/modules/vm/VmTile.qml
@@ -0,0 +1,43 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// One dot per VM, green for running. The state behind these comes from the
+// lifecycle event stream, which runs whether or not the page is open, so the
+// dots are current without the stats poll.
+Row {
+ spacing: 4
+
+ Repeater {
+ model: Virsh.names
+
+ Rectangle {
+ required property string modelData
+ readonly property string state: Virsh.vms[modelData]?.state ?? ""
+
+ anchors.verticalCenter: parent.verticalCenter
+ width: 7; height: 7; radius: 4
+ color: state === "running" ? Theme.green
+ : state === "paused" || state === "suspended" ? Theme.yellow
+ : Theme.overlay
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: Virsh.names.length === 0
+ text: "no VMs"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+}
diff --git a/desktop/shell.qml b/desktop/shell.qml
index d27583d..74c1ec2 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -15,6 +15,7 @@ import Quickshell.Wayland
import "modules/appearance"
import "modules/mail"
import "modules/sound"
+import "modules/vm"
ShellRoot {
// Quickshell exits once no window is visible, and the drawer is closed
@@ -35,6 +36,7 @@ ShellRoot {
SoundModule {},
MailModule {},
AppearanceModule {},
+ VmModule {},
]
}
diff --git a/vm-manager/Button.qml b/vm-manager/Button.qml
deleted file mode 100644
index d6ca78f..0000000
--- a/vm-manager/Button.qml
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-
-import QtQuick
-
-Rectangle {
- id: btn
- property alias text: label.text
- property bool danger: false
- property bool enabled: true
- signal clicked
-
- implicitWidth: label.implicitWidth + 22
- implicitHeight: label.implicitHeight + 12
- radius: 7
- opacity: enabled ? 1 : 0.4
- color: area.containsMouse && enabled
- ? Qt.alpha(danger ? Theme.red : Theme.accent, 0.25)
- : Qt.alpha(Theme.surface, 0.8)
- border.width: 1
- border.color: Qt.alpha(danger ? Theme.red : Theme.text, 0.2)
-
- Text {
- id: label
- anchors.centerIn: parent
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: danger ? Theme.red : Theme.text
- }
-
- MouseArea {
- id: area
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (btn.enabled) btn.clicked()
- }
-}
diff --git a/vm-manager/Theme.qml b/vm-manager/Theme.qml
deleted file mode 120000
index 3d2e40f..0000000
--- a/vm-manager/Theme.qml
+++ /dev/null
@@ -1 +0,0 @@
-../shared/Theme.qml \ No newline at end of file
diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml
deleted file mode 100644
index 5960a1a..0000000
--- a/vm-manager/VmPanel.qml
+++ /dev/null
@@ -1,457 +0,0 @@
-// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-
-import Quickshell
-import Quickshell.Wayland
-import QtQuick
-
-Scope {
- id: root
-
- // The screen the drawer opens on. Falls back to the focused one when
- // this monitor is not connected, so the panel is never invisible.
- property string monitor: "DP-3"
-
- property bool open: false
- property string selected: ""
-
- // A pending destructive action, shown as a confirm step instead of the
- // action list: { kind, vm, snap }. Null when nothing is being confirmed.
- property var confirming: null
- property string typed: ""
-
- readonly property var screenObj:
- Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
-
- function toggle() { root.open = !root.open; }
- function close() { root.open = false; root.confirming = null; root.typed = ""; }
-
- onOpenChanged: {
- Virsh.sampling = open;
- confirming = null;
- typed = "";
- if (open) {
- Virsh.refreshList();
- if (!selected && Virsh.names.length) selected = Virsh.names[0];
- if (selected) Virsh.loadSnapshots(selected);
- }
- }
-
- onSelectedChanged: if (selected) Virsh.loadSnapshots(selected)
-
- function fmtBytes(b) {
- if (b < 0) return "—";
- const g = b / (1024 * 1024 * 1024);
- return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB";
- }
-
- function stateColor(s) {
- if (s === "running") return Theme.green;
- if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow;
- if (s === "crashed") return Theme.red;
- return Theme.overlay;
- }
-
- // Which verbs make sense in the current state, mirroring the states the
- // old rofi script switched on.
- function actionsFor(s, saved) {
- if (s === "running")
- return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
- ["reset", "Reset"], ["destroy", "Force stop"]];
- if (s === "paused" || s === "suspended")
- return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
- // Only worth offering when a saved image actually exists: without one
- // managedsave-remove fails, and the button would be noise on every
- // other VM.
- if (saved)
- return [["start", "Start"], ["discardsave", "Discard saved state"]];
- return [["start", "Start"]];
- }
-
- function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
-
- function run(vm, action) {
- if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" };
- else Virsh.act(vm, action);
- }
-
- // Quickshell exits once no window is visible, and the drawer is closed
- // most of the time, so a 1x1 transparent window holds the process open.
- // Without it the shell quits and the keybind, which reaches it over IPC,
- // has nothing left to talk to.
- PanelWindow {
- visible: true
- implicitWidth: 1
- implicitHeight: 1
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- mask: Region {}
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
- }
-
- LazyLoader {
- active: root.open
-
- PanelWindow {
- id: win
- screen: root.screenObj
-
- anchors { top: true; left: true; right: true; bottom: true }
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- WlrLayershell.layer: WlrLayer.Overlay
- WlrLayershell.namespace: "quickshell-vm-manager"
- // The panel needs keys: Escape to close, typing to confirm a delete.
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
-
- // Dim the screen behind: the secondary monitor usually has an
- // editor or a chat window on it, and this says the drawer has focus.
- Rectangle {
- anchors.fill: parent
- color: "#000000"
- opacity: 0.45
- MouseArea { anchors.fill: parent; onClicked: root.close() }
- }
-
- // Keys reach an item, not the window, so something inside has to
- // hold focus. The TextInput in a delete confirmation takes it when
- // it appears; this takes it back whenever that is gone.
- Item {
- id: keyCatcher
- anchors.fill: parent
- focus: true
-
- Keys.onEscapePressed: {
- if (root.confirming) root.confirming = null;
- else root.close();
- }
-
- Connections {
- target: root
- function onConfirmingChanged() {
- if (!root.confirming) keyCatcher.forceActiveFocus();
- }
- }
- }
-
- Rectangle {
- id: drawer
- anchors { top: parent.top; left: parent.left; right: parent.right }
- height: Math.min(content.implicitHeight + 40, win.height - 60)
- color: Qt.alpha(Theme.base, 0.72)
- bottomLeftRadius: 14
- bottomRightRadius: 14
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.12)
-
- // Clicks on the drawer must not fall through to the dimmer.
- MouseArea { anchors.fill: parent }
-
- Column {
- id: content
- anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 }
- spacing: 16
-
- Row {
- spacing: 10
- Text {
- text: "Virtual machines"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true }
- color: Theme.text
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Esc to close"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: Theme.overlay
- }
- }
-
- // One row per VM, so several VMs stay readable at a glance.
- Repeater {
- model: Virsh.names
-
- Rectangle {
- required property string modelData
- readonly property var vm: Virsh.vms[modelData] ?? ({})
- readonly property bool isSel: root.selected === modelData
-
- width: content.width
- implicitHeight: vmCol.implicitHeight + 24
- radius: 10
- color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35)
- border.width: 1
- border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent"
-
- MouseArea {
- anchors.fill: parent
- onClicked: root.selected = modelData
- }
-
- Column {
- id: vmCol
- anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
- spacing: 10
-
- Row {
- spacing: 10
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: 9; height: 9; radius: 5
- color: root.stateColor(vm.state ?? "")
- }
- Text {
- text: modelData
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
- color: Theme.text
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: vm.state ?? ""
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Theme.subtext
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: (vm.vcpus ?? 0) + " vCPU"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Theme.overlay
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: vm.state === "running" && !(vm.agent ?? false)
- text: "agent starting"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: Theme.overlay
- }
- }
-
- // Stats only mean anything while the VM runs.
- Row {
- visible: vm.state === "running"
- spacing: 24
-
- Stat {
- label: "CPU"
- value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%"
- fraction: (vm.cpu ?? 0) / 100
- }
- Stat {
- label: "RAM"
- value: (vm.memUsed ?? -1) < 0 ? "—"
- : root.fmtBytes(vm.memUsed) + " / " + root.fmtBytes(vm.memTotal)
- fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal
- }
- Stat {
- label: "Disk"
- value: (vm.fsUsed ?? -1) < 0 ? "—"
- : root.fmtBytes(vm.fsUsed) + " / " + root.fmtBytes(vm.fsTotal)
- fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal
- }
- Stat {
- label: "Address"
- value: (vm.ip ?? "") === "" ? "—" : vm.ip
- fraction: -1
- }
- }
-
- // Actions and snapshots, for the selected VM only.
- Loader {
- active: isSel
- width: parent.width
- sourceComponent: detail
- property string vmName: modelData
- property string vmState: vm.state ?? ""
- property bool vmSaved: vm.saved ?? false
- }
- }
- }
- }
- }
- }
- }
- }
-
- Component {
- id: detail
-
- Column {
- spacing: 12
-
- readonly property string vmName: parent.vmName
- readonly property string vmState: parent.vmState
- readonly property bool vmSaved: parent.vmSaved
-
- Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
-
- // Confirm step replaces the buttons, so the action cannot be
- // clicked again while it is being confirmed.
- Loader {
- active: root.confirming !== null && root.confirming.vm === vmName
- width: parent.width
- sourceComponent: confirmUi
- }
-
- Flow {
- visible: !(root.confirming !== null && root.confirming.vm === vmName)
- width: parent.width
- spacing: 8
-
- Repeater {
- model: root.actionsFor(vmState, vmSaved)
- Button {
- required property var modelData
- text: modelData[1]
- danger: root.isDestructive(modelData[0])
- onClicked: root.run(vmName, modelData[0])
- }
- }
- Button {
- text: "Snapshot"
- onClicked: Virsh.snapshotCreate(vmName)
- }
- Button {
- text: "Delete VM"
- danger: true
- onClicked: root.confirming = { kind: "delete", vm: vmName, snap: "" }
- }
- }
-
- Text {
- visible: (Virsh.snapshots[vmName] ?? []).length > 0
- text: "Snapshots"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
- color: Theme.subtext
- }
-
- Repeater {
- model: Virsh.snapshots[vmName] ?? []
-
- Row {
- required property var modelData
- width: parent.width
- spacing: 10
-
- Text {
- anchors.verticalCenter: parent.verticalCenter
- width: 260
- elide: Text.ElideRight
- text: modelData.name
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Theme.text
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- width: 150
- text: modelData.created
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: Theme.overlay
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- width: 70
- text: modelData.state
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: Theme.overlay
- }
- Button {
- text: "Revert"
- danger: true
- onClicked: root.confirming = { kind: "revert", vm: vmName, snap: modelData.name }
- }
- Button {
- text: "Delete"
- danger: true
- onClicked: root.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name }
- }
- }
- }
- }
- }
-
- Component {
- id: confirmUi
-
- Column {
- spacing: 10
- readonly property var c: root.confirming
- // Deleting a VM erases its disk image, so that one asks for the
- // name to be typed. The rest are recoverable enough for a click.
- readonly property bool needsTyping: c && c.kind === "delete"
-
- Text {
- width: parent.width
- wrapMode: Text.Wrap
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
- color: Theme.red
- text: {
- if (!c) return "";
- if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`;
- if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
- if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
- if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
- if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
- if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
- return "";
- }
- }
-
- TextInput {
- id: nameField
- visible: needsTyping
- width: 260
- text: root.typed
- onTextChanged: root.typed = text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
- color: Theme.text
- focus: needsTyping
- Component.onCompleted: if (needsTyping) forceActiveFocus()
-
- Rectangle {
- anchors.fill: parent
- anchors.margins: -6
- z: -1
- radius: 6
- color: Qt.alpha(Theme.surface, 0.8)
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.15)
- }
- Text {
- visible: !nameField.text
- text: "type the VM name"
- font: nameField.font
- color: Theme.overlay
- }
- }
-
- Row {
- spacing: 8
- Button {
- text: "Confirm"
- danger: true
- enabled: !needsTyping || root.typed === c.vm
- onClicked: {
- if (c.kind === "delete") Virsh.deleteVm(c.vm);
- else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap);
- else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap);
- else Virsh.act(c.vm, c.kind);
- root.confirming = null;
- root.typed = "";
- }
- }
- Button {
- text: "Cancel"
- onClicked: { root.confirming = null; root.typed = ""; }
- }
- }
- }
- }
-}
diff --git a/vm-manager/shell.qml b/vm-manager/shell.qml
deleted file mode 100644
index 6bd926b..0000000
--- a/vm-manager/shell.qml
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-ShellRoot {
- VmPanel { id: panel }
-
- // Bound to a key in Hyprland: `qs -p <this dir> ipc call panel toggle`.
- IpcHandler {
- target: "panel"
- function toggle() { panel.toggle(); }
- function show() { panel.open = true; }
- function hide() { panel.close(); }
- }
-
- // An action that fails (libvirt refusing, a disk in use) has to say so:
- // the panel is the only feedback, since virsh output goes nowhere here.
- Process {
- id: notify
- property string body: ""
- }
- Connections {
- target: Virsh
- function onActionFailed(vm, action, message) {
- notify.command = ["notify-send", "--app-name=vm-manager", "--urgency=critical",
- "--icon=error", `${action} failed: ${vm}`, message];
- notify.running = true;
- }
- }
-}