+ }
);
diff --git a/src/components/vm/vmEditDescriptionDialog.jsx b/src/components/vm/vmEditDescriptionDialog.jsx
new file mode 100644
index 000000000..c22966f5d
--- /dev/null
+++ b/src/components/vm/vmEditDescriptionDialog.jsx
@@ -0,0 +1,82 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from 'cockpit';
+import React, { useState } from 'react';
+import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
+import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
+import { TextArea } from "@patternfly/react-core/dist/esm/components/TextArea";
+
+import { ModalError } from 'cockpit-components-inline-notification.jsx';
+import { useDialogs } from 'dialogs.jsx';
+
+import { isObjectEmpty } from '../../helpers.js';
+import { domainSetDescription } from '../../libvirtApi/domain.js';
+
+const _ = cockpit.gettext;
+
+export const EditDescriptionDialog = ({ vm }) => {
+ const Dialogs = useDialogs();
+ const [description, setDescription] = useState(vm.inactiveXML.description || "");
+ const [error, dialogErrorSet] = useState({});
+
+ async function onSubmit() {
+ try {
+ await domainSetDescription(vm, description);
+ Dialogs.close();
+ } catch (exc) {
+ dialogErrorSet({
+ dialogError: cockpit.format(_("Failed to set description of VM $0"), vm.name),
+ dialogErrorDetail: exc.message
+ });
+ }
+ }
+
+ return (
+
+
+
+ >
+ }>
+
+
+ );
+};
diff --git a/src/libvirt-xml-parse.js b/src/libvirt-xml-parse.js
index 74492abc0..12077038e 100644
--- a/src/libvirt-xml-parse.js
+++ b/src/libvirt-xml-parse.js
@@ -245,6 +245,8 @@ export function parseDomainDumpxml(connectionName, domXml, objPath) {
const metadataElem = getSingleOptionalElem(domainElem, "metadata");
const name = domainElem.getElementsByTagName("name")[0].childNodes[0].nodeValue;
+ const uuid = domainElem.getElementsByTagName("uuid")[0].childNodes[0].nodeValue;
+ const description = domainElem.getElementsByTagName("description")[0]?.childNodes[0]?.nodeValue;
const id = objPath;
const osType = osTypeElem.childNodes[0].nodeValue;
const osBoot = parseDumpxmlForOsBoot(osBootElems);
@@ -292,7 +294,9 @@ export function parseDomainDumpxml(connectionName, domXml, objPath) {
return {
connectionName,
+ uuid,
name,
+ description,
id,
osType,
osBoot,
diff --git a/src/libvirtApi/domain.js b/src/libvirtApi/domain.js
index 5c9aa0dfd..b2dcc1450 100644
--- a/src/libvirtApi/domain.js
+++ b/src/libvirtApi/domain.js
@@ -890,6 +890,33 @@ export function domainSendNMI({
return call(connectionName, objPath, 'org.libvirt.Domain', 'InjectNMI', [0], { timeout, type: 'u' });
}
+function shlex_quote(str) {
+ // yay, command line apis...
+ return "'" + str.replaceAll("'", "'\"'\"'") + "'";
+}
+
+async function domainSetXML(vm, option, values) {
+ const opts = { err: "message" };
+ if (vm.connectionName === 'system')
+ opts.superuser = 'try';
+
+ // We don't pass the arguments for virt-xml through a shell, but
+ // virt-xml does its own parsing with the Python shlex module. So
+ // we need to do the equivalent of shlex.quote here.
+
+ const args = [];
+ for (const key in values)
+ args.push(shlex_quote(key + '=' + values[key]));
+
+ await cockpit.spawn([
+ 'virt-xml', '-c', `qemu:///${vm.connectionName}`, '--' + option, args.join(','), vm.uuid, '--edit'
+ ], opts);
+}
+
+export async function domainSetDescription(vm, description) {
+ await domainSetXML(vm, "metadata", { description });
+}
+
export function domainSetCpuMode({
name,
id: objPath,
diff --git a/test/check-machines-lifecycle b/test/check-machines-lifecycle
index 06e08ada6..868c6ac64 100755
--- a/test/check-machines-lifecycle
+++ b/test/check-machines-lifecycle
@@ -365,6 +365,30 @@ class TestMachinesLifecycle(machineslib.VirtualMachinesCase):
b.wait_text("h2.vm-name", "test%")
b.wait_not_present("#navbar-oops")
+ def testEditDescription(self):
+ b = self.browser
+
+ self.createVm("mac", running=False)
+ self.login_and_go("/machines")
+ self.waitPageInit()
+ self.goToVmPage("mac")
+
+ self.performAction("mac", "edit-description")
+
+ # Non-ascii chars, unbalanced quotes, backslash, multiple lines
+ desc1 = '"Döscrü\\ptiän \'\'\' كرة القدم'
+ desc2 = 'Second line'
+
+ # On input we need to use "\r" to get a line break, but on
+ # output it will come back as "\n"...
+ b.set_input_text("#edit-description-dialog-description", desc1 + "\r" + desc2, value_check=False)
+ b.wait_val("#edit-description-dialog-description", desc1 + "\n" + desc2)
+ b.click("#edit-description-dialog-confirm")
+ b.wait_not_present("#edit-description-dialog-confirm")
+
+ b.wait_text(".vm-description p:nth-child(1)", desc1)
+ b.wait_text(".vm-description p:nth-child(2)", desc2)
+
def testDelete(self):
b = self.browser
m = self.machine