Skip to content

Commit

Permalink
vm: Support for VM descriptions
Browse files Browse the repository at this point in the history
Based on work by @Britz, thanks a lot!
  • Loading branch information
mvollmer committed Jul 16, 2024
1 parent 1e3ffd5 commit 10af9f1
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 1 deletion.
15 changes: 14 additions & 1 deletion src/components/vm/vmActions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ConfirmDialog } from './confirmDialog.jsx';
import { DeleteDialog } from "./deleteDialog.jsx";
import { MigrateDialog } from './vmMigrateDialog.jsx';
import { RenameDialog } from './vmRenameDialog.jsx';
import { EditDescriptionDialog } from './vmEditDescriptionDialog.jsx';
import { ReplaceSpiceDialog } from './vmReplaceSpiceDialog.jsx';
import {
domainCanDelete,
Expand Down Expand Up @@ -451,9 +452,21 @@ const VmActions = ({ vm, vms, onAddErrorNotification, isDetailsPage }) => {
{_("Rename")}
</DropdownItem>
);
dropdownItems.push(<Divider key="separator-rename" />);
}

if (isDetailsPage) {
dropdownItems.push(
<DropdownItem key={`${id}-edit-description`}
id={`${id}-edit-description`}
onClick={() => Dialogs.show(<EditDescriptionDialog vm={vm} />)}>
{_("Edit description")}
</DropdownItem>
);
}

if (domainCanRename(state) || isDetailsPage)
dropdownItems.push(<Divider key="separator-rename" />);

if (state !== undefined && domainCanDelete(state, vm.id)) {
if (!vm.persistent) {
dropdownItems.push(
Expand Down
6 changes: 6 additions & 0 deletions src/components/vm/vmDetailsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export const VmDetailsPage = ({
<VmNeedsShutdown vm={vm} />
<VmUsesSpice vm={vm} />
</div>
{
vm.inactiveXML.description &&
<div className="vm-description">
{vm.inactiveXML.description.split("\n").map((p, i) => <p key={i}>{p}</p>)}
</div>
}
</PageSection>
);

Expand Down
82 changes: 82 additions & 0 deletions src/components/vm/vmEditDescriptionDialog.jsx
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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 (
<Modal position="top" variant="small" isOpen onClose={Dialogs.close}
title={cockpit.format(_("Edit description of VM $0"), vm.name)}
footer={
<>
<Button variant='primary'
id="edit-description-dialog-confirm"
onClick={onSubmit}>
{_("Save")}
</Button>
<Button variant='link' onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</>
}>
<Form onSubmit={e => {
e.preventDefault();
onSubmit();
}}
isHorizontal>
{!isObjectEmpty(error) && <ModalError dialogError={error.dialogError} dialogErrorDetail={error.dialogErrorDetail} />}
<FormGroup label={_("Description")}
fieldId="edit-description-dialog-description">
<TextArea id='edit-description-dialog-description'
value={description}
onChange={(_, value) => setDescription(value)} />
</FormGroup>
</Form>
</Modal>
);
};
4 changes: 4 additions & 0 deletions src/libvirt-xml-parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -292,7 +294,9 @@ export function parseDomainDumpxml(connectionName, domXml, objPath) {

return {
connectionName,
uuid,
name,
description,
id,
osType,
osBoot,
Expand Down
27 changes: 27 additions & 0 deletions src/libvirtApi/domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions test/check-machines-lifecycle
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,29 @@ 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 <b>line</b>'

# 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_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
Expand Down

0 comments on commit 10af9f1

Please sign in to comment.