diff --git a/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx b/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx index 8214c3aac..1b47fb3ff 100644 --- a/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx +++ b/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx @@ -17,7 +17,7 @@ * along with Cockpit; If not, see . */ import cockpit from "cockpit"; -import React from "react"; +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"; @@ -30,7 +30,11 @@ import { DialogsContext } from 'dialogs.jsx'; import { ModalError } from "cockpit-components-inline-notification.jsx"; import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx"; import { snapshotCreate, snapshotGetAll } from "../../../libvirtApi/snapshot.js"; -import { getSortedBootOrderDevices, LIBVIRT_SYSTEM_CONNECTION } from "../../../helpers.js"; +import { + getSortedBootOrderDevices, LIBVIRT_SYSTEM_CONNECTION, + units, convertToBestUnit, formatWithBestUnit, + dirname +} from "../../../helpers.js"; import { domainGet } from '../../../libvirtApi/domain.js'; const _ = cockpit.gettext; @@ -64,10 +68,17 @@ const DescriptionRow = ({ onValueChanged, description }) => { ); }; -function getDefaultMemoryPath(vm, snapName) { - // Choosing a default path where memory snapshot should be stored might be tricky. Ideally we want - // to store it in the same directory where the primary disk (the disk which is first booted) is stored - // If howver no such disk can be found, we should fallback to libvirt's default /var/lib/libvirt +function getDefaultMemoryLocation(vm) { + // If we find an existing external snapshot, use it's memory path + // as the default. Otherwise, try to find the primary disk and use + // it's location. If that fails as well, use a reasonable hard + // coded value. + + for (const s of vm.snapshots.sort((a, b) => b.creationTime - a.creationTime)) { + if (s.memoryPath) + return dirname(s.memoryPath); + } + const devices = getSortedBootOrderDevices(vm).filter(d => d.bootOrder && d.device.device === "disk" && d.device.type === "file" && @@ -75,30 +86,49 @@ function getDefaultMemoryPath(vm, snapName) { if (devices.length > 0) { const primaryDiskPath = devices[0].device.source.file; const directory = primaryDiskPath.substring(0, primaryDiskPath.lastIndexOf("/") + 1); - return directory + snapName; + return directory; } else { if (vm.connectionName === LIBVIRT_SYSTEM_CONNECTION) - return "/var/lib/libvirt/memory/" + snapName; + return "/var/lib/libvirt/memory/"; else if (current_user) - return current_user.home + "/.local/share/libvirt/memory/" + snapName; + return current_user.home + "/.local/share/libvirt/memory/"; } return ""; } -const MemoryPathRow = ({ onValueChanged, memoryPath, validationError }) => { +const MemoryLocationRow = ({ onValueChanged, memoryLocation, validationError, available, needed }) => { return ( - + onValueChanged("memoryPath", value)} - superuser="try" - isOptionCreatable - value={memoryPath} /> - + onChange={value => onValueChanged("memoryLocation", value)} + value={memoryLocation} /> + ); }; +function get_available_space(path, callback) { + if (!path) + callback(null); + + cockpit.spawn(["stat", "-f", "-c", '{ "unit": %S, "free": %a }', path], { superuser: "try" }) + .then(output => { + const info = JSON.parse(output); + callback(info.free * info.unit); + }) + .catch(exc => { + // channel has already logged the error + callback(null); + }); +} + export class CreateSnapshotModal extends React.Component { static contextType = DialogsContext; @@ -107,11 +137,12 @@ export class CreateSnapshotModal extends React.Component { // cut off seconds, subseconds, and timezone const now = new Date().toISOString() .replace(/:[^:]*$/, ''); - const snapName = props.vm.name + '_' + now; + const snapName = now; this.state = { name: snapName, description: "", - memoryPath: getDefaultMemoryPath(props.vm, snapName), + memoryLocation: getDefaultMemoryLocation(props.vm), + available: null, inProgress: false, }; @@ -121,8 +152,18 @@ export class CreateSnapshotModal extends React.Component { this.onCreate = this.onCreate.bind(this); } + updateAvailableSpace(path) { + get_available_space(path, val => this.setState({ available: val })); + } + onValueChanged(key, value) { this.setState({ [key]: value }); + if (key == "memoryLocation") { + // We don't need to debounce this. The "memoryLocation" + // state is not changed on each keypress, but only when + // the input is blurred. + this.updateAvailableSpace(value); + } } dialogErrorSet(text, detail) { @@ -130,7 +171,7 @@ export class CreateSnapshotModal extends React.Component { } onValidate() { - const { name, memoryPath } = this.state; + const { name, memoryLocation } = this.state; const { vm, isExternal } = this.props; const validationError = {}; @@ -139,8 +180,8 @@ export class CreateSnapshotModal extends React.Component { else if (!name) validationError.name = _("Name can not be empty"); - if (isExternal && vm.state === "running" && !memoryPath) - validationError.memory = _("Memory file can not be empty"); + if (isExternal && vm.state === "running" && !memoryLocation) + validationError.memory = _("Memory save location can not be empty"); return validationError; } @@ -148,17 +189,24 @@ export class CreateSnapshotModal extends React.Component { onCreate() { const Dialogs = this.context; const { vm, isExternal } = this.props; - const { name, description, memoryPath } = this.state; + const { name, description, memoryLocation } = this.state; const validationError = this.onValidate(); if (!Object.keys(validationError).length) { this.setState({ inProgress: true }); + let mpath = null; + if (isExternal && vm.state === "running" && memoryLocation) { + mpath = memoryLocation; + if (mpath[mpath.length-1] != "/") + mpath = mpath + "/"; + mpath = mpath + vm.name + "." + name + ".save"; + } snapshotCreate({ vm, name, description, isExternal, - memoryPath: isExternal && vm.state === "running" && memoryPath, + memoryPath: mpath, }) .then(() => { // VM Snapshots do not trigger any events so we have to refresh them manually @@ -175,10 +223,14 @@ export class CreateSnapshotModal extends React.Component { } } + componentDidMount() { + this.updateAvailableSpace(this.state.memoryLocation); + } + render() { const Dialogs = this.context; const { idPrefix, isExternal, vm } = this.props; - const { name, description, memoryPath } = this.state; + const { name, description, memoryLocation, available } = this.state; const validationError = this.onValidate(); const body = ( @@ -186,8 +238,10 @@ export class CreateSnapshotModal extends React.Component { {isExternal && vm.state === 'running' && - } + } ); diff --git a/src/helpers.js b/src/helpers.js index efe73696c..e50be3e97 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -145,6 +145,12 @@ export function convertToUnitVerbose(input, inputUnit, outputUnit) { return result; } +export function formatWithBestUnit(input, inputUnit) { + const best = convertToBestUnit(input, inputUnit || units.B); + const decimals = best.value >= 100 ? 0 : best.value >= 10 ? 1 : 2; + return cockpit.format("$0 $1", best.value.toFixed(decimals), best.unit); +} + export function isEmpty(str) { return (!str || str.length === 0); } @@ -907,3 +913,11 @@ export function vmSupportsExternalSnapshots(config, vm, storagePools) { return true; } + +export function dirname(path) { + const i = path.lastIndexOf("/"); + if (i < 0) + return null; + else + return path.substr(0, i); +} diff --git a/src/libvirt-xml-parse.js b/src/libvirt-xml-parse.js index 74492abc0..4da8bd7a1 100644 --- a/src/libvirt-xml-parse.js +++ b/src/libvirt-xml-parse.js @@ -216,14 +216,16 @@ export function parseDomainSnapshotDumpxml(snapshot) { const nameElem = getSingleOptionalElem(snapElem, 'name'); const descElem = getSingleOptionalElem(snapElem, 'description'); const parentElem = getSingleOptionalElem(snapElem, 'parent'); + const memElem = getSingleOptionalElem(snapElem, 'memory'); const name = nameElem?.childNodes[0].nodeValue; const description = descElem?.childNodes[0].nodeValue; const parentName = parentElem?.getElementsByTagName("name")[0].childNodes[0].nodeValue; const state = snapElem.getElementsByTagName("state")[0].childNodes[0].nodeValue; const creationTime = snapElem.getElementsByTagName("creationTime")[0].childNodes[0].nodeValue; + const memoryPath = memElem?.getAttribute("file"); - return { name, description, state, creationTime, parentName }; + return { name, description, state, creationTime, parentName, memoryPath }; } export function parseDomainDumpxml(connectionName, domXml, objPath) {