diff --git a/src/actions/options.js b/src/actions/options.js index 668137a65..0f5db99a3 100644 --- a/src/actions/options.js +++ b/src/actions/options.js @@ -72,7 +72,22 @@ export function loadUserOptions (userOptions: RemoteUserOptionsType): LoadUserOp } } -export function saveGlobalOptions ({ values: { sshKey, language, showNotifications, notificationSnoozeDuration, refreshInterval, persistLocale } = {} }: Object, { transactionId }: Object): SaveGlobalOptionsActionType { +export function saveGlobalOptions ({ + values: { + sshKey, + language, + persistLocale, + showNotifications, + notificationSnoozeDuration, + refreshInterval, + preferredConsole, + fullScreenVnc, + ctrlAltEndVnc, + fullScreenSpice, + ctrlAltEndSpice, + smartcardSpice, + } = {}, +}: Object, { transactionId }: Object): SaveGlobalOptionsActionType { return { type: C.SAVE_GLOBAL_OPTIONS, payload: { @@ -82,6 +97,12 @@ export function saveGlobalOptions ({ values: { sshKey, language, showNotificatio showNotifications, notificationSnoozeDuration, refreshInterval, + preferredConsole, + fullScreenVnc, + ctrlAltEndVnc, + fullScreenSpice, + ctrlAltEndSpice, + smartcardSpice, }, meta: { transactionId, diff --git a/src/actions/types.js b/src/actions/types.js index 2194b5368..227869f57 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -17,7 +17,13 @@ export type SaveGlobalOptionsActionType = { persistLocale?: boolean, showNotifications?: boolean, notificationSnoozeDuration?: number, - sshKey?: string + sshKey?: string, + preferredConsole?: string, + fullScreenVnc?: boolean, + ctrlAltEndVnc?: boolean, + fullScreenSpice?: boolean, + ctrlAltEndSpice?: boolean, + smartcardSpice?: boolean |}, meta: {| transactionId: string diff --git a/src/components/Settings/SettingsBase.js b/src/components/Settings/SettingsBase.js index 8c98b7b1e..f1248c668 100644 --- a/src/components/Settings/SettingsBase.js +++ b/src/components/Settings/SettingsBase.js @@ -62,13 +62,16 @@ Section.propTypes = { } const SettingsBase = ({ name, section }) => { + const sections = section.sections ? Object.entries(section.sections) : [[name, section]] return (
- -
-
-
-
+ { sections.map(([name, section]) => + +
+
+
+
+ )}
) } diff --git a/src/components/UserSettings/GlobalSettings.js b/src/components/UserSettings/GlobalSettings.js index 90cb1f610..d11b1b935 100644 --- a/src/components/UserSettings/GlobalSettings.js +++ b/src/components/UserSettings/GlobalSettings.js @@ -13,6 +13,7 @@ import { Settings, SettingsBase } from '../Settings' import SelectBox from '../SelectBox' import moment from 'moment' import AppConfiguration from '_/config' +import { BROWSER_VNC, NATIVE_VNC, SPICE, RDP } from '_/constants/console' const GENERAL_SECTION = 'general' @@ -59,8 +60,30 @@ class GlobalSettings extends Component { ] } + preferredConsoleList (msg) { + return [ + { + id: NATIVE_VNC, + value: msg.vncConsole(), + }, + { + id: BROWSER_VNC, + value: msg.vncConsoleBrowser(), + }, + { + id: SPICE, + value: msg.spiceConsole(), + }, + { + id: RDP, + value: msg.remoteDesktop(), + }, + ] + } + constructor (props) { super(props) + const { config } = props /** * Typical flow (happy path): * 1. at the begining: @@ -99,6 +122,12 @@ class GlobalSettings extends Component { refreshInterval: AppConfiguration.schedulerFixedDelayInSeconds, notificationSnoozeDuration: AppConfiguration.notificationSnoozeDurationInMinutes, persistLocale: AppConfiguration.persistLocale, + fullScreenVnc: false, + fullScreenSpice: false, + ctrlAltEndVnc: false, + ctrlAltEndSpice: false, + preferredConsole: config.defaultUiConsole, + smartcardSpice: AppConfiguration.smartcardSpice, }, } this.handleCancel = this.handleCancel.bind(this) @@ -181,22 +210,6 @@ class GlobalSettings extends Component { ), }))('language'), - ((name) => ({ - title: msg.sshKey(), - tooltip: msg.sshKeyTooltip(), - name, - body: ( -
- onChange(name)(e.target.value)} - value={draftValues[name] || ''} - rows={8} - /> -
- ), - }))('sshKey'), ], }, refreshInterval: { @@ -255,6 +268,144 @@ class GlobalSettings extends Component { }))('notificationSnoozeDuration'), ], }, + console: { + title: msg.console(), + fields: [ ], + sections: { + console: { + title: msg.console(), + tooltip: msg.globalSettingsTooltip(), + fields: [], + }, + preferredConsole: { + title: '', + fields: [ + ((name) => ({ + title: msg.preferredConsole(), + tooltip: msg.preferredConsoleTooltip(), + name, + body: ( +
+ ({ + id, + value, + isDefault: id === config.defaultUiConsole, + })) + } + selected={draftValues[name]} + onChange={onChange(name)} + /> +
+ ), + }))('preferredConsole'), + ], + }, + vnc: { + title: msg.vncOptions(), + fields: [ + ((name) => ({ + title: msg.fullScreenMode(), + name, + body: ( + { + onChange(name)(fullScreen) + }} + /> + ), + }))('fullScreenVnc'), + ((name) => ({ + title: msg.ctrlAltEnd(), + tooltip: msg.remapCtrlAltDelete(), + name, + body: ( + { + onChange(name)(ctrlAltEnd) + }} + /> + ), + }))('ctrlAltEndVnc'), + ], + }, + spice: { + title: msg.spiceOptions(), + fields: [ + ((name) => ({ + title: msg.fullScreenMode(), + name, + body: ( + { + onChange(name)(fullScreen) + }} + /> + ), + }))('fullScreenSpice'), + ((name) => ({ + title: msg.ctrlAltEnd(), + tooltip: msg.remapCtrlAltDelete(), + name, + body: ( + { + onChange(name)(ctrlAltEnd) + }} + /> + ), + }))('ctrlAltEndSpice'), + ((name) => ({ + title: msg.smartcard(), + tooltip: msg.smartcardTooltip(), + name, + body: ( + { + onChange(name)(smartcard) + }} + /> + ), + }))('smartcardSpice'), + ], + }, + serial: { + title: msg.serialConsoleOptions(), + fields: [ + ((name) => ({ + title: msg.sshKey(), + tooltip: msg.sshKeyTooltip(), + name, + body: ( +
+ onChange(name)(e.target.value)} + value={draftValues[name] || ''} + rows={8} + /> +
+ ), + }))('sshKey'), + ], + }, + }, + + }, advancedOptions: { title: msg.advancedOptions(), fields: [ @@ -347,6 +498,7 @@ export default connect( config: { userName: config.getIn(['user', 'name']), email: config.getIn(['user', 'email']), + defaultUiConsole: config.getIn(['defaultUiConsole']), }, currentValues: { sshKey: options.getIn(['ssh', 'key']), @@ -355,6 +507,12 @@ export default connect( notificationSnoozeDuration: options.getIn(['localOptions', 'notificationSnoozeDuration']), refreshInterval: options.getIn(['remoteOptions', 'refreshInterval', 'content']), persistLocale: options.getIn(['remoteOptions', 'persistLocale', 'content']), + fullScreenVnc: options.getIn(['remoteOptions', 'fullScreenVnc', 'content']), + fullScreenSpice: options.getIn(['remoteOptions', 'fullScreenSpice', 'content']), + ctrlAltEndVnc: options.getIn(['remoteOptions', 'ctrlAltEndVnc', 'content']), + ctrlAltEndSpice: options.getIn(['remoteOptions', 'ctrlAltEndSpice', 'content']), + preferredConsole: options.getIn(['remoteOptions', 'preferredConsole', 'content'], config.getIn(['defaultUiConsole'])), + smartcardSpice: options.getIn(['remoteOptions', 'smartcardSpice', 'content']), }, lastTransactionId: options.getIn(['lastTransactions', 'global', 'transactionId'], ''), }), diff --git a/src/components/VmActions/index.js b/src/components/VmActions/index.js index e62db6d0b..2711c88a4 100644 --- a/src/components/VmActions/index.js +++ b/src/components/VmActions/index.js @@ -128,6 +128,7 @@ class VmActions extends React.Component { onSuspend, onRDP, msg, + preferredConsole, } = this.props const isPoolVm = !!vm.getIn(['pool', 'id'], false) const isPool = !!pool && !isPoolVm @@ -137,7 +138,6 @@ class VmActions extends React.Component { const vncConsole = vm.get('consoles').find(c => c.get('protocol') === VNC) const spiceConsole = vm.get('consoles').find(c => c.get('protocol') === SPICE) const hasRdp = isWindows(vm.getIn(['os', 'type'])) - const defaultUiConsole = config.get('defaultUiConsole') let consoles = [] if (vncConsole) { @@ -197,7 +197,7 @@ class VmActions extends React.Component { } consoles = consoles - .map(({ uiConsole, ...props }) => ({ ...props, priority: uiConsole === defaultUiConsole ? 1 : 0 })) + .map(({ uiConsole, ...props }) => ({ ...props, priority: uiConsole === preferredConsole ? 1 : 0 })) .sort((a, b) => b.priority - a.priority) const actions = [ @@ -391,6 +391,7 @@ VmActions.propTypes = { onStartVm: PropTypes.func.isRequired, onRDP: PropTypes.func.isRequired, msg: PropTypes.object.isRequired, + preferredConsole: PropTypes.string, } export default withRouter( @@ -398,6 +399,7 @@ export default withRouter( (state, { vm }) => ({ isEditable: vm.get('canUserEditVm') && state.clusters.find(cluster => cluster.get('canUserUseCluster')) !== undefined, config: state.config, + preferredConsole: state.options.getIn(['remoteOptions', 'preferredConsole', 'content'], state.config.get('defaultUiConsole')), }), (dispatch, { vm, pool }) => ({ onShutdown: () => dispatch(shutdownVm({ vmId: vm.get('id'), force: false })), diff --git a/src/config.js b/src/config.js index 9b3aa708a..d78b45672 100644 --- a/src/config.js +++ b/src/config.js @@ -16,6 +16,7 @@ const AppConfiguration = { notificationSnoozeDurationInMinutes: 10, showNotificationsDefault: true, persistLocale: true, + smartcardSpice: true, consoleClientResourcesURL: 'https://www.ovirt.org/documentation/admin-guide/virt/console-client-resources/', cockpitPort: '9090', @@ -27,7 +28,7 @@ export const DefaultEngineOptions = Object.seal({ MaxNumOfThreadsPerCpu: 8, MaxNumOfVmCpusPerArch: `{${DEFAULT_ARCH}=1}`, - SpiceUsbAutoShare: 1, + SpiceUsbAutoShare: true, getUSBFilter: {}, UserSessionTimeOutInterval: 30, diff --git a/src/ovirtapi/transform.js b/src/ovirtapi/transform.js index 776d749f3..801d54d7a 100644 --- a/src/ovirtapi/transform.js +++ b/src/ovirtapi/transform.js @@ -1034,12 +1034,24 @@ const RemoteUserOptions = { locale, refreshInterval, persistLocale, + preferredConsole, + fullScreenVnc, + ctrlAltEndVnc, + fullScreenSpice, + ctrlAltEndSpice, + smartcardSpice, } = fromEntries return { locale, refreshInterval, persistLocale, + preferredConsole, + fullScreenVnc, + ctrlAltEndVnc, + fullScreenSpice, + ctrlAltEndSpice, + smartcardSpice, } }, } @@ -1171,6 +1183,7 @@ const EngineOptionMaxNumOfVmCpusPerArch = { // Export each transforms individually so they can be consumed individually // export { + convertBool, VM, Pool, CdRom, diff --git a/src/ovirtapi/types.js b/src/ovirtapi/types.js index 072e09342..591839fa3 100644 --- a/src/ovirtapi/types.js +++ b/src/ovirtapi/types.js @@ -232,9 +232,15 @@ export type GlobalUserSettingsType = {| |} export type RemoteUserOptionsType = {| - locale: Object, + locale?: UserOptionType, refreshInterval?: UserOptionType, - persistLocale?: UserOptionType + persistLocale?: UserOptionType, + preferredConsole?: UserOptionType, + fullScreenVnc?: UserOptionType, + ctrlAltEndVnc?: UserOptionType, + fullScreenSpice?: UserOptionType, + ctrlAltEndSpice?: UserOptionType, + smartcardSpice?: UserOptionType |} export type UserOptionsType = {| diff --git a/src/reducers/options.js b/src/reducers/options.js index 3ed17a829..590674ee8 100644 --- a/src/reducers/options.js +++ b/src/reducers/options.js @@ -27,6 +27,26 @@ const defaultOptions: UserOptionsType = { id: undefined, content: AppConfiguration.schedulerFixedDelayInSeconds, }, + fullScreenVnc: { + id: undefined, + content: false, + }, + ctrlAltEndVnc: { + id: undefined, + content: false, + }, + fullScreenSpice: { + id: undefined, + content: false, + }, + ctrlAltEndSpice: { + id: undefined, + content: false, + }, + smartcardSpice: { + id: undefined, + content: AppConfiguration.smartcardSpice, + }, }, ssh: undefined, lastTransactions: {}, diff --git a/src/sagas/console/index.js b/src/sagas/console/index.js index a9ad9b8e2..c55c0a8cb 100644 --- a/src/sagas/console/index.js +++ b/src/sagas/console/index.js @@ -62,14 +62,14 @@ export function* downloadVmConsole (action) { /** *Download console if type is spice or novnc is running already */ - if (data.indexOf('type=spice') > -1 || !isNoVNC) { - let options = yield select(state => state.options.getIn(['options', 'consoleOptions', vmId])) - if (!options) { - console.log('downloadVmConsole() console options not yet present, trying to load from local storage') - options = yield getConsoleOptions(getConsoleOptionsAction({ vmId })) - } - - data = adjustVVFile({ data, options, usbAutoshare, usbFilter }) + const isSpice = data.indexOf('type=spice') > -1 + if (isSpice || !isNoVNC) { + const legacyOptions = getLegacyOptions({ vmId }) + const options = isSpice + ? yield getSpiceConsoleOptions({ legacyOptions, usbAutoshare, usbFilter, vmId }) + : yield getVncOptions({ legacyOptions }) + + data = adjustVVFile({ data, options }) fileDownload({ data, fileName: `console.vv`, mimeType: 'application/x-virt-viewer' }) yield put(setConsoleStatus({ vmId, status: DOWNLOAD_CONSOLE })) } else { @@ -86,6 +86,50 @@ export function* downloadVmConsole (action) { } } +/** + * Legacy options were saved in browser's local storage. + * The UI for setting theose options was removed in previous versions. + * However there is still a (small) chance that the data is still there. + * Note that legacy options are per VM and therefore should overwrite global defaults. + */ +function* getLegacyOptions ({ vmId }) { + const options = yield select(state => state.options.getIn(['options', 'consoleOptions', vmId])) + if (options) { + return (options.toJS && options.toJS()) || options + } + console.log('downloadVmConsole() console options not yet present, trying to load from local storage') + yield getConsoleOptions(getConsoleOptionsAction({ vmId })) +} + +function* getSpiceConsoleOptions ({ legacyOptions, usbAutoshare, usbFilter, vmId }) { + const smartcardEnabledOnVm = yield select(({ vms }) => vms.getIn(['vms', vmId, 'display', 'smartcardEnabled'])) + const newOptions = yield select(({ options, vms }) => ({ + fullscreen: options.getIn(['remoteOptions', 'fullScreenSpice', 'content']), + ctrlAltDelToEnd: options.getIn(['remoteOptions', 'ctrlAltEndSpice', 'content']), + smartcardEnabled: options.getIn(['remoteOptions', 'smartcardSpice', 'content']), + })) + + const merged = { + ...newOptions, + ...legacyOptions, + usbFilter, + usbAutoshare, + } + merged.smartcardEnabled = smartcardEnabledOnVm && merged.smartcardEnabled + return merged +} + +function* getVncOptions ({ legacyOptions }) { + const newOptions = yield select(({ options }) => ({ + fullscreen: options.getIn(['remoteOptions', 'fullScreenVnc', 'content']), + ctrlAltDelToEnd: options.getIn(['remoteOptions', 'ctrlAltEndVnc', 'content']), + })) + return { + ...newOptions, + ...legacyOptions, + } +} + /** * Push a RDP connection file (__console.rdp__) to connect a user to the Windows VM's RDP session */ diff --git a/src/sagas/console/vvFileUtils.js b/src/sagas/console/vvFileUtils.js index f100fb5c7..1d0092b76 100644 --- a/src/sagas/console/vvFileUtils.js +++ b/src/sagas/console/vvFileUtils.js @@ -1,15 +1,12 @@ -export function adjustVVFile ({ data, options, usbAutoshare, usbFilter }) { - // __options__ can either be a plain JS object or ImmutableJS Map - console.log('adjustVVFile options:', options) - - if (options && ((options.get && options.get('fullscreen')) || options.fullscreen)) { +export function adjustVVFile ({ data = '', options: { fullscreen, ctrlAltDelToEnd, smartcardEnabled, usbAutoshare, usbFilter } = {} } = {}) { + if (fullscreen) { data = data.replace(/^fullscreen=0/mg, 'fullscreen=1') } const pattern = /^secure-attention=.*$/mg let text = 'secure-attention=ctrl+alt+del' - if (options && ((options.get && options.get('ctrlAltDelToEnd')) || options.ctrlAltDelToEnd)) { + if (ctrlAltDelToEnd) { text = 'secure-attention=ctrl+alt+end' } if (data.match(pattern)) { @@ -20,20 +17,14 @@ export function adjustVVFile ({ data, options, usbAutoshare, usbFilter }) { data = data.replace(/^\[virt-viewer\]$/mg, `[virt-viewer]\n${text}`) // ending \n is already there } - const isSpice = data.indexOf('type=spice') > -1 - - if (usbFilter && isSpice) { + if (usbFilter) { data = data.replace(/^\[virt-viewer\]$/mg, `[virt-viewer]\nusb-filter=${usbFilter}`) data = data.replace(/^usb-filter=null\n/mg, '') // remove an extra 'usb-filter=null' line if present } - if (options && isSpice) { - const smartcardEnabled = options.get ? options.get('smartcardEnabled') : options.smartcardEnabled - data = data.replace(/^enable-smartcard=[01]$/mg, `enable-smartcard=${smartcardEnabled ? 1 : 0}`) - } + data = data.replace(/^enable-smartcard=[01]$/mg, `enable-smartcard=${smartcardEnabled ? 1 : 0}`) - // make USB Auto-Share to be enabled/disabled in VM Portal according to the SpiceUsbAutoShare config value - data = data.replace(/^enable-usb-autoshare=.*$/mg, `enable-usb-autoshare=${usbAutoshare.toString() === 'true' ? 1 : 0}`) + data = data.replace(/^enable-usb-autoshare=.*$/mg, `enable-usb-autoshare=${usbAutoshare ? 1 : 0}`) console.log('adjustVVFile data after adjustment:', data) return data diff --git a/src/sagas/console/vvFileUtils.test.js b/src/sagas/console/vvFileUtils.test.js new file mode 100644 index 000000000..8518a9d84 --- /dev/null +++ b/src/sagas/console/vvFileUtils.test.js @@ -0,0 +1,43 @@ +import { adjustVVFile } from './vvFileUtils' + +describe('test error handling', function () { + it('should return empty string when no data', function () { + expect(adjustVVFile()).toEqual('') + }) + it('should add secure attention when missing', function() { + const data = '[virt-viewer]\ntype=vnc\n' + expect(adjustVVFile({data})).toEqual('[virt-viewer]\nsecure-attention=ctrl+alt+del\ntype=vnc\n') + }) +}) + +describe('test flags one-by-one', function () { + it('should set fullscreen', function () { + const data = '[virt-viewer]\nfullscreen=0\nsecure-attention=ctrl+alt+del\n' + const options = {fullscreen: true} + expect(adjustVVFile({data, options},)).toEqual('[virt-viewer]\nfullscreen=1\nsecure-attention=ctrl+alt+del\n') + }) + + it('should set secure-attention', function () { + const data = '[virt-viewer]\nsecure-attention=ctrl+alt+del\n' + const options = {ctrlAltDelToEnd: true} + expect(adjustVVFile({data, options},)).toEqual('[virt-viewer]\nsecure-attention=ctrl+alt+end\n') + }) + + it('should set usb filter', function () { + const data = '[virt-viewer]\nusb-filter=null\nsecure-attention=ctrl+alt+del\n' + const options = {usbFilter: '1,1,1,1'} + expect(adjustVVFile({data, options},)).toEqual('[virt-viewer]\nusb-filter=1,1,1,1\nsecure-attention=ctrl+alt+del\n') + }) + + it('should set enable-smartcard', function () { + const data = '[virt-viewer]\nenable-smartcard=0\nsecure-attention=ctrl+alt+del\n' + const options = {smartcardEnabled: true} + expect(adjustVVFile({data, options},)).toEqual('[virt-viewer]\nenable-smartcard=1\nsecure-attention=ctrl+alt+del\n') + }) + + it('should set enable-usb-autoshare', function () { + const data = '[virt-viewer]\nenable-usb-autoshare=0\nsecure-attention=ctrl+alt+del\n' + const options = {usbAutoshare: true} + expect(adjustVVFile({data, options},)).toEqual('[virt-viewer]\nenable-usb-autoshare=1\nsecure-attention=ctrl+alt+del\n') + }) +}) \ No newline at end of file diff --git a/src/sagas/options.js b/src/sagas/options.js index 341023f21..b1cc9512b 100644 --- a/src/sagas/options.js +++ b/src/sagas/options.js @@ -166,18 +166,38 @@ function* saveLocale ([ localePropName, submittedLocale ]: any, persistLocale: b return {} } -function* saveGlobalOptions ({ payload: { sshKey, showNotifications, notificationSnoozeDuration, language, refreshInterval, persistLocale }, meta: { transactionId } }: SaveGlobalOptionsActionType): Generator { - const { ssh, locale, refresh, shouldPersistLocale } = yield all({ +function* saveGlobalOptions ({ payload: { + sshKey, + showNotifications, + notificationSnoozeDuration, + language, + refreshInterval, + persistLocale, + preferredConsole, + fullScreenVnc, + ctrlAltEndVnc, + fullScreenSpice, + ctrlAltEndSpice, + smartcardSpice, +}, meta: { transactionId } }: SaveGlobalOptionsActionType): Generator { + const { ssh, locale, shouldPersistLocale, ...standardRemoteOptions } = yield all({ ssh: call(saveSSHKey, ...Object.entries({ sshKey })), locale: call(saveLocale, ...Object.entries({ locale: language }), persistLocale), shouldPersistLocale: call(saveRemoteOption, ...Object.entries({ persistLocale })), refresh: call(saveRemoteOption, ...Object.entries({ refreshInterval })), + preferredConsole: call(saveRemoteOption, ...Object.entries({ preferredConsole })), + fullScreenVnc: call(saveRemoteOption, ...Object.entries({ fullScreenVnc })), + ctrlAltEndVnc: call(saveRemoteOption, ...Object.entries({ ctrlAltEndVnc })), + fullScreenSpice: call(saveRemoteOption, ...Object.entries({ fullScreenSpice })), + ctrlAltEndSpice: call(saveRemoteOption, ...Object.entries({ ctrlAltEndSpice })), + smartcardSpice: call(saveRemoteOption, ...Object.entries({ smartcardSpice })), }) - if (!refresh.error && refresh.change && !refresh.sameAsCurrent) { - const { name, value } = refresh.data - yield put(A.setOption({ key: [ 'remoteOptions', name ], value })) - } + yield all( + ((Object.values(standardRemoteOptions): Array): Array) + .filter(result => !result.error && result.change && !result.sameAsCurrent) + .map(({ data: { name, value } }) => put(A.setOption({ key: [ 'remoteOptions', name ], value }))) + ) if (!shouldPersistLocale.error && shouldPersistLocale.change && !shouldPersistLocale.sameAsCurrent) { const { name, value } = shouldPersistLocale.data diff --git a/src/sagas/server-configs.js b/src/sagas/server-configs.js index 9b9c09c92..64869a8e2 100644 --- a/src/sagas/server-configs.js +++ b/src/sagas/server-configs.js @@ -46,7 +46,7 @@ export function* fetchServerConfiguredValues () { })) if (eo.usbAutoShare) { - yield put(setSpiceUsbAutoShare(eo.usbAutoShare)) + yield put(setSpiceUsbAutoShare(Transforms.convertBool(eo.usbAutoShare))) } if (eo.usbFilter) {