1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-22 18:50:08 +03:00

F : Implement Guacamole remote console ()

(cherry picked from commit 1154d568c34a089b79af3314ec94fe0c5fcbe1e8)
This commit is contained in:
Sergio Betanzos 2022-03-17 16:58:50 +01:00 committed by Tino Vazquez
parent 710ea8080d
commit 5695b00d2b
No known key found for this signature in database
GPG Key ID: 14201E424D02047E
60 changed files with 2780 additions and 614 deletions
src/fireedge

File diff suppressed because it is too large Load Diff

@ -81,6 +81,7 @@
"fast-xml-parser": "3.19.0",
"fs-extra": "9.0.1",
"fuse.js": "6.4.1",
"guacamole-common-js": "1.3.1",
"helmet": "4.1.1",
"http": "0.0.1-security",
"http-proxy-middleware": "1.0.5",

@ -62,8 +62,8 @@ const Sunstone = ({ store = {}, location = '' }) => (
Sunstone.propTypes = {
location: PropTypes.string,
context: PropTypes.shape({}),
store: PropTypes.shape({}),
context: PropTypes.object,
store: PropTypes.object,
}
Sunstone.displayName = 'SunstoneApp'

@ -27,10 +27,14 @@ const Dashboard = loadable(
const Settings = loadable(() => import('client/containers/Settings'), {
ssr: false,
})
const Guacamole = loadable(() => import('client/containers/Guacamole'), {
ssr: false,
})
export const PATH = {
DASHBOARD: '/dashboard',
SETTINGS: '/settings',
GUACAMOLE: '/guacamole/:id/:type',
}
export const ENDPOINTS = [
@ -50,6 +54,12 @@ export const ENDPOINTS = [
position: -1,
Component: Settings,
},
{
label: 'Guacamole',
disabledSidebar: true,
path: PATH.GUACAMOLE,
Component: Guacamole,
},
]
/**

@ -0,0 +1,108 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useMemo, useCallback, ReactElement } from 'react'
import PropTypes from 'prop-types'
import { useHistory, generatePath } from 'react-router-dom'
import {
AppleImac2021 as VncIcon,
TerminalOutline as SshIcon,
Windows as RdpIcon,
} from 'iconoir-react'
import { SubmitButton } from 'client/components/FormControl'
import { useViews } from 'client/features/Auth'
import { useLazyGetGuacamoleSessionQuery } from 'client/features/OneApi/vm'
import {
nicsIncludesTheConnectionType,
isAvailableAction,
} from 'client/models/VirtualMachine'
import { Translate } from 'client/components/HOC'
import { T, VM, RESOURCE_NAMES, VM_ACTIONS } from 'client/constants'
import { PATH } from 'client/apps/sunstone/routes'
const GUACAMOLE_BUTTON = {
vnc: { tooltip: T.Vnc, icon: <VncIcon /> },
ssh: { tooltip: T.Ssh, icon: <SshIcon /> },
rdp: { tooltip: T.Rdp, icon: <RdpIcon /> },
}
const GuacamoleButton = memo(
/**
* @param {object} options - Options
* @param {VM} options.vm - Virtual machine
* @param {'vnc'|'ssh'|'rdp'} options.connectionType - Connection type
* @param {Function} [options.onClick] - Handle click for button
* @returns {ReactElement} - Guacamole button
*/
({ vm, connectionType, onClick }) => {
const history = useHistory()
const { view, [RESOURCE_NAMES.VM]: vmView } = useViews()
const [getSession, { isLoading }] = useLazyGetGuacamoleSessionQuery()
const isDisabled = useMemo(() => {
const noAction = vmView?.actions?.[connectionType] !== true
const noAvailable = isAvailableAction(connectionType)(vm)
return noAction || noAvailable
}, [view, vm])
const { tooltip, icon } = GUACAMOLE_BUTTON[connectionType]
const goToConsole =
onClick ??
useCallback(
async (evt) => {
try {
evt.stopPropagation()
const params = { id: vm?.ID, type: connectionType }
await getSession(params)
history.push(generatePath(PATH.GUACAMOLE, params))
} catch {}
},
[vm?.ID, connectionType, history]
)
if (
isDisabled ||
(connectionType !== VM_ACTIONS.VNC &&
!nicsIncludesTheConnectionType(vm, connectionType))
) {
return null
}
return (
<SubmitButton
data-cy={`${vm?.ID}-${connectionType}`}
icon={icon}
tooltip={<Translate word={tooltip} />}
isSubmitting={isLoading}
onClick={goToConsole}
/>
)
}
)
GuacamoleButton.propTypes = {
vm: PropTypes.object,
connectionType: PropTypes.string,
onClick: PropTypes.func,
}
GuacamoleButton.displayName = 'GuacamoleButton'
export { GuacamoleButton }

@ -15,3 +15,4 @@
* ------------------------------------------------------------------------- */
export * from 'client/components/Buttons/ScheduleAction'
export * from 'client/components/Buttons/ConsoleAction'

@ -36,9 +36,10 @@ const VirtualMachineCard = memo(
* @param {object} props - Props
* @param {VM} props.vm - Virtual machine resource
* @param {object} props.rootProps - Props to root component
* @param {ReactElement} [props.actions] - Actions
* @returns {ReactElement} - Card
*/
({ vm, rootProps }) => {
({ vm, rootProps, actions }) => {
const classes = rowStyles()
const { ID, NAME, UNAME, GNAME, IPS, STIME, ETIME, LOCK } = vm
@ -89,6 +90,7 @@ const VirtualMachineCard = memo(
</Stack>
</div>
)}
{actions && <div className={classes.actions}>{actions}</div>}
</div>
)
}
@ -99,6 +101,7 @@ VirtualMachineCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
actions: PropTypes.any,
}
VirtualMachineCard.displayName = 'VirtualMachineCard'

@ -0,0 +1,213 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useCallback, useState, ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Refresh, Maximize, Camera } from 'iconoir-react'
import {
Tooltip,
Typography,
Button,
IconButton,
CircularProgress,
} from '@mui/material'
import { Translate } from 'client/components/HOC'
import { downloadFile } from 'client/utils'
import { T, GuacamoleSession } from 'client/constants'
const GuacamoleCtrlAltDelButton = memo(
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {ReactElement} Guacamole mouse plugin
*/
(session) => {
const { id, client } = session
const handleClick = useCallback(() => {
if (!client) return
const ctrlKey = 65507
const altKey = 65513
const delKey = 65535
client?.sendKeyEvent(1, ctrlKey)
client?.sendKeyEvent(1, altKey)
client?.sendKeyEvent(1, delKey)
client?.sendKeyEvent(0, delKey)
client?.sendKeyEvent(0, altKey)
client?.sendKeyEvent(0, ctrlKey)
}, [client])
return (
<Button
data-cy={`${id}-ctrl-alt-del-button`}
onClick={handleClick}
disableElevation
variant="outlined"
color="error"
>
<Translate word={T.SendCtrlAltDel} />
</Button>
)
}
)
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {ReactElement} Guacamole mouse plugin
*/
const GuacamoleReconnectButton = (session) => {
const { id, isLoading, handleReconnect } = session
const [reconnecting, setReconnecting] = useState(false)
const handleReconnectSession = async () => {
if (isLoading) return
setReconnecting(true)
await handleReconnect()
setReconnecting(false)
}
return (
<Tooltip
arrow
placement="bottom"
title={
<Typography variant="subtitle2">
<Translate word={T.Reconnect} />
</Typography>
}
>
<IconButton
data-cy={`${id}-reconnect-button`}
onClick={handleReconnectSession}
>
{reconnecting || isLoading ? (
<CircularProgress color="secondary" size={20} />
) : (
<Refresh />
)}
</IconButton>
</Tooltip>
)
}
const GuacamoleFullScreenButton = memo(
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {ReactElement} Guacamole mouse plugin
*/
(session) => {
const { id, viewport } = session
const handleClick = useCallback(() => {
// If the document is not in full screen mode make the video full screen
if (!document.fullscreenElement && document.fullscreenEnabled) {
viewport?.requestFullscreen?.()
} else if (document.exitFullscreen) {
document.exitFullscreen()
}
}, [viewport])
return (
<Tooltip
arrow
placement="bottom"
title={
<Typography variant="subtitle2">
<Translate word={T.FullScreen} />
</Typography>
}
>
<IconButton data-cy={`${id}-fullscreen-button`} onClick={handleClick}>
<Maximize />
</IconButton>
</Tooltip>
)
}
)
const GuacamoleScreenshotButton = memo(
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {ReactElement} Guacamole mouse plugin
*/
(session) => {
const { id, client } = session
const handleClick = useCallback(() => {
if (!client) return
const canvas = client.getDisplay().getDefaultLayer().getCanvas()
canvas.toBlob((blob) => {
downloadFile(new File([blob], 'screenshot.png'))
}, 'image/png')
}, [client])
return (
<Tooltip
arrow
placement="bottom"
title={
<Typography variant="subtitle2">
<Translate word={T.Screenshot} />
</Typography>
}
>
<IconButton data-cy={`${id}-screenshot-button`} onClick={handleClick}>
<Camera />
</IconButton>
</Tooltip>
)
}
)
const ButtonPropTypes = {
client: PropTypes.object,
viewport: PropTypes.object,
}
GuacamoleCtrlAltDelButton.displayName = 'GuacamoleCtrlAltDelButton'
GuacamoleCtrlAltDelButton.propTypes = ButtonPropTypes
GuacamoleReconnectButton.displayName = 'GuacamoleReconnectButton'
GuacamoleReconnectButton.propTypes = ButtonPropTypes
GuacamoleFullScreenButton.displayName = 'GuacamoleFullScreenButton'
GuacamoleFullScreenButton.propTypes = ButtonPropTypes
GuacamoleScreenshotButton.displayName = 'GuacamoleScreenshotButton'
GuacamoleScreenshotButton.propTypes = ButtonPropTypes
export {
GuacamoleCtrlAltDelButton,
GuacamoleReconnectButton,
GuacamoleFullScreenButton,
GuacamoleScreenshotButton,
}

@ -0,0 +1,217 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useRef, useEffect, RefObject } from 'react'
import { WebSocketTunnel, Tunnel, Client } from 'guacamole-common-js'
import { useGeneralApi } from 'client/features/General'
import { useGuacamole, useGuacamoleApi } from 'client/features/Guacamole'
import { getConnectString, clientStateToString } from 'client/models/Guacamole'
import { fakeDelay, isDevelopment } from 'client/utils'
import {
GuacamoleSession, // eslint-disable-line no-unused-vars
SOCKETS,
GUACAMOLE_STATES_STR,
THUMBNAIL_UPDATE_FREQUENCY,
} from 'client/constants'
const {
CONNECTING,
CONNECTED,
DISCONNECTING,
DISCONNECTED,
CLIENT_ERROR,
TUNNEL_ERROR,
} = GUACAMOLE_STATES_STR
// eslint-disable-next-line jsdoc/valid-types
/**
* @typedef {GuacamoleSession & {
* handleConnect: Function,
* handleDisconnect: Function,
* handleReconnect: function():Promise,
* }} GuacamoleClientType
*/
/**
* @param {object} options - Client options
* @param {string} options.id - Session includes type and VM id. Eg: '6-vnc'
* @param {RefObject} options.display - Session display. Only exists if display plugins is enabled
* @returns {GuacamoleClientType} Guacamole client props
*/
const GuacamoleClient = ({ id, display }) => {
const guac = useRef(createGuacamoleClient()).current
// Automatically update the client thumbnail
guac.client.onsync = () => handleUpdateThumbnail()
const { enqueueError, enqueueInfo, enqueueSuccess } = useGeneralApi()
const { token, ...session } = useGuacamole(id)
const {
setConnectionState,
setTunnelUnstable,
setMultiTouchSupport,
updateThumbnail,
} = useGuacamoleApi(id)
const handleConnect = (width, height, force = false) => {
if (!session?.isUninitialized && !session.isDisconnected && !force) return
isDevelopment() && console.log(`connect ${id} 🔵`)
const options = { token, display, width, height }
const connectString = getConnectString(options)
guac.client.connect(connectString)
}
const handleDisconnect = () => {
try {
isDevelopment() && console.log(`disconnect ${id} 🔴`)
guac.client?.disconnect()
} catch {}
}
const handleReconnect = async (width, height) => {
session?.isConnected && handleDisconnect()
// sleep to avoid quick reconnection
await fakeDelay(1500)
handleConnect(width, height, true)
}
/**
* Store the thumbnail of the given managed client within the connection
* history under its associated ID. If the client is not connected, this
* function has no effect.
*/
const handleUpdateThumbnail = () => {
const nowTimestamp = new Date().getTime()
const lastTimestamp = session?.thumbnail?.timestamp
if (
lastTimestamp &&
nowTimestamp - lastTimestamp < THUMBNAIL_UPDATE_FREQUENCY
)
return
const clientDisplay = guac.client?.getDisplay()
if (clientDisplay?.getWidth() <= 0 || clientDisplay?.getHeight() <= 0)
return
// Get screenshot
const canvas = clientDisplay.flatten()
// Calculate scale of thumbnail (max 320x240, max zoom 100%)
const scale = Math.min(320 / canvas.width, 240 / canvas.height, 1)
// Create thumbnail canvas
const thumbnail = document.createElement('canvas')
thumbnail.width = canvas.width * scale
thumbnail.height = canvas.height * scale
// Scale screenshot to thumbnail
const context = thumbnail.getContext('2d')
context.drawImage(
canvas,
0,
0,
canvas.width,
canvas.height,
0,
0,
thumbnail.width,
thumbnail.height
)
thumbnail.toBlob((blob) => {
const url = URL.createObjectURL(blob)
const newThumbnail = { timestamp: nowTimestamp, canvas: url }
updateThumbnail({ thumbnail: newThumbnail })
}, 'image/webp')
}
useEffect(() => {
guac.tunnel.onerror = (status) => {
setConnectionState({ state: TUNNEL_ERROR, statusCode: status.code })
}
guac.tunnel.onstatechange = (state) => {
;({
[Tunnel.State.CONNECTING]: () => {
setConnectionState({ state: CONNECTING })
},
[Tunnel.State.OPEN]: () => {
setTunnelUnstable({ unstable: false })
},
[Tunnel.State.UNSTABLE]: () => {
setTunnelUnstable({ unstable: true })
},
[Tunnel.State.CLOSED]: () => {
setConnectionState({ state: DISCONNECTED })
},
}[state]?.())
}
guac.client.onstatechange = (state) => {
const stateString = clientStateToString(state)
const isDisconnect = [DISCONNECTING, DISCONNECTED].includes(stateString)
const isDisconnected = DISCONNECTED === stateString
const isConnected = CONNECTED === stateString
isConnected && enqueueSuccess('Connection established')
isDisconnected && enqueueInfo('Disconnected')
!isDisconnect && setConnectionState({ state: stateString })
}
guac.client.onerror = (status) => {
enqueueError(status.message)
setConnectionState({ state: CLIENT_ERROR, statusCode: status.code })
}
guac.client.onmultitouch = (layer, touches) => {
setMultiTouchSupport({ touches })
}
return () => {
handleDisconnect()
}
}, [id])
useEffect(() => {
session?.isError && handleDisconnect()
}, [session?.isError])
useEffect(() => {
!session.isConnected && handleConnect()
}, [token])
return { token, ...session, ...guac, handleReconnect }
}
const createGuacamoleClient = () => {
const { protocol, host } = window.location
const websocketProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
const guacamoleWs = `${websocketProtocol}//${host}/fireedge/${SOCKETS.GUACAMOLE}`
const tunnel = new WebSocketTunnel(guacamoleWs)
const client = new Client(tunnel)
return { client, tunnel }
}
export default GuacamoleClient

@ -0,0 +1,63 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable no-unused-vars */
/* eslint-disable jsdoc/valid-types */
import { useRef } from 'react'
import {
useGetLatest,
reducePlugin,
} from 'client/components/Consoles/Guacamole/utils'
import GuacamoleClient, {
GuacamoleClientType,
} from 'client/components/Consoles/Guacamole/client'
import {
GuacamoleDisplayPlugin,
GuacamoleKeyboardPlugin,
GuacamoleMousePlugin,
} from 'client/components/Consoles/Guacamole/plugins'
/**
* Creates guacamole session.
*
* @param {object} options - Options
* @param {string} options.id - Session includes type and VM id. Eg: '6-vnc'
* @param {...any} [plugins] - Plugins
* @returns {GuacamoleClientType &
* GuacamoleDisplayPlugin &
* GuacamoleKeyboardPlugin &
* GuacamoleMousePlugin} session
*/
const useGuacamoleSession = (options, ...plugins) => {
// Create the guacamole instance
const instanceRef = useRef({})
const getInstance = useGetLatest(instanceRef.current)
// Assign the options to the instance
Object.assign(getInstance(), { ...options })
// Assign the session and plugins to the instance
Object.assign(
getInstance(),
[GuacamoleClient, ...plugins].reduce(reducePlugin, getInstance())
)
return getInstance()
}
export * from 'client/components/Consoles/Guacamole/plugins'
export * from 'client/components/Consoles/Guacamole/buttons'
export { useGuacamoleSession }

@ -0,0 +1,158 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useEffect, useState } from 'react'
import {
StringReader,
StringWriter,
BlobReader,
BlobWriter,
} from 'guacamole-common-js'
import { GuacamoleSession } from 'client/constants'
import { isDevelopment } from 'client/utils'
const createClipboardData = ({ id, type, data } = {}) => ({
source: id,
/**
* The mimetype of the data currently stored within the clipboard.
*
* @type {string}
*/
type: type || 'text/plain',
/**
* The data currently stored within the clipboard.
*
* @type {string|Blob|File}
*/
data: data ?? '',
})
/**
* @param {GuacamoleSession} session - Current session
* @returns {null} null
*/
const GuacamoleClipboard = (session) => {
const { id, client, isConnected } = session ?? {}
const [pendingRead, setPendingRead] = useState(() => false)
const [storedClipboard, storeClipboard] = useState(() =>
createClipboardData({ id })
)
const getLocalClipboard = async () => {
try {
if (pendingRead) return
const text = await navigator.clipboard.readText()
storeClipboard((prev) => ({ ...prev, data: text, type: 'text/plain' }))
return text
} finally {
setPendingRead(false)
}
}
const setLocalClipboard = async ({ data, type }) => {
if (type !== 'text/plain') return
await navigator.clipboard.writeText(data)
storeClipboard((prev) => ({ ...prev, data, type }))
}
const setClientClipboard = ({ data, type = 'text/plain' } = {}) => {
// Create stream with proper mimetype
const stream = client.createClipboardStream(type)
// Send data as a string if it is stored as a string
if (typeof data === 'string') {
const writer = new StringWriter(stream)
writer.sendText(data)
writer.sendEnd()
}
// Otherwise, assume the data is a File/Blob
else {
// Write File/Blob asynchronously
const writer = new BlobWriter(stream)
writer.oncomplete = () => {
writer.sendEnd()
}
// Begin sending data
writer.sendBlob(data)
}
}
const resyncClipboard = async () => {
try {
const localClipboard = await getLocalClipboard()
setClientClipboard({ data: localClipboard })
} catch (e) {
isDevelopment() && console.log(e)
}
}
const focusGained = (evt) => {
// Only recheck clipboard if it's the window itself that gained focus
evt.target === window && resyncClipboard()
}
useEffect(() => {
if (!isConnected) return
;(async () => await resyncClipboard())()
window.addEventListener('load', resyncClipboard, true)
window.addEventListener('copy', resyncClipboard)
window.addEventListener('cut', resyncClipboard)
window.addEventListener('focus', focusGained, true)
client.onclipboard = (stream, mimetype) => {
// If the received data is text, read it as a simple string
if (/^text\//.exec(mimetype)) {
const reader = new StringReader(stream)
// Assemble received data into a single string
let data = ''
reader.ontext = (text) => {
data += text
}
// Set clipboard contents once stream is finished
reader.onend = () => {
setLocalClipboard({ data, type: mimetype })
}
}
// Otherwise read the clipboard data as a Blob
else {
const reader = new BlobReader(stream, mimetype)
reader.onend = () => {
setLocalClipboard({ data: reader.getBlob(), type: mimetype })
}
}
}
return () => {
window.removeEventListener('load', resyncClipboard, true)
window.removeEventListener('copy', resyncClipboard)
window.removeEventListener('cut', resyncClipboard)
window.removeEventListener('focus', focusGained, true)
}
}, [isConnected])
return { storedClipboard }
}
export { GuacamoleClipboard }

@ -0,0 +1,147 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import {
useEffect,
useMemo,
useRef,
MutableRefObject, // eslint-disable-line no-unused-vars
ReactElement, // eslint-disable-line no-unused-vars
} from 'react'
import { styled } from '@mui/material'
import { GuacamoleSession } from 'client/constants'
/**
* @typedef GuacamoleDisplayPlugin
* @property {MutableRefObject} [display] - Display object
* @property {MutableRefObject} [viewport] - Viewport object is the wrapper of display
* @property {ReactElement} [displayElement] - Display element
*/
const Viewport = styled('div')({
backgroundColor: '#222431',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
placeContent: 'center',
})
const Display = styled('div')({
zIndex: 1,
overflow: 'hidden',
'& > *': { cursor: 'none' },
})
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {GuacamoleDisplayPlugin} Guacamole display plugin
*/
const GuacamoleDisplay = (session) => {
const { id, container, header, client, isConnected } = session ?? {}
const isSSH = useMemo(() => id.includes('ssh'), [id])
const viewportRef = useRef(null)
const displayRef = useRef(null)
const containerResized = () => {
if (!client || !container) return
const clientDisplay = client.getDisplay()
const pixelDensity = window.devicePixelRatio || 1
const headerHeight = header?.offsetHeight ?? 0
const width = document.fullscreenElement
? window.innerWidth * pixelDensity
: container.offsetWidth * pixelDensity
const height = document.fullscreenElement
? window.innerHeight * pixelDensity
: (container.offsetHeight - headerHeight) * pixelDensity
if (
clientDisplay.getWidth() !== width ||
clientDisplay.getHeight() !== height
) {
client.sendSize(width, height)
}
// when type connection is SSH, display doesn't need scale
id.includes('vnc') && updateDisplayScale()
}
const updateDisplayScale = () => {
if (!client) return
const clientDisplay = client.getDisplay()
// Get screen resolution.
const origHeight = Math.max(clientDisplay.getHeight(), 1)
const origWidth = Math.max(clientDisplay.getWidth(), 1)
const headerHeight = header?.offsetHeight ?? 0
const containerWidth = document.fullscreenElement
? window.innerWidth
: container.offsetWidth
const containerHeight = document.fullscreenElement
? window.innerHeight
: container.offsetHeight - headerHeight
const xScale = containerWidth / origWidth
const yScale = containerHeight / origHeight
// This is done to handle both X and Y axis
let scale = Math.min(yScale, xScale)
// Limit to 1
scale = Math.min(scale, 1)
scale !== 0 && clientDisplay.scale(scale)
}
useEffect(() => {
if (!isConnected) return
const display = displayRef.current
const clientDisplay = client.getDisplay()
display?.appendChild(clientDisplay.getElement())
const pollResize = setInterval(containerResized, 10)
return () => {
display?.childNodes.forEach((node) => display?.removeChild(node))
clearInterval(pollResize)
}
}, [isConnected])
return {
display: displayRef.current,
viewport: viewportRef.current,
displayElement: (
<Viewport ref={viewportRef}>
<Display
ref={displayRef}
sx={isSSH ? { width: '100%', height: '100%' } : {}}
/>
</Viewport>
),
}
}
export { GuacamoleDisplay }

@ -0,0 +1,20 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export * from 'client/components/Consoles/Guacamole/plugins/clipboard'
export * from 'client/components/Consoles/Guacamole/plugins/display'
export * from 'client/components/Consoles/Guacamole/plugins/keyboard'
export * from 'client/components/Consoles/Guacamole/plugins/mouse'

@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
// eslint-disable-next-line no-unused-vars
import { useCallback, useEffect, useRef, useState } from 'react'
import { Keyboard } from 'guacamole-common-js'
import { GuacamoleSession } from 'client/constants'
/**
* @typedef GuacamoleKeyboardPlugin
* @property {Keyboard} [keyboard] - Guacamole keyboard
*/
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {GuacamoleKeyboardPlugin} Guacamole keyboard plugin
*/
const GuacamoleKeyboard = (session) => {
const { client, isConnected } = session ?? {}
const keyboardRef = useRef(null)
useEffect(() => {
if (!isConnected) return
keyboardRef.current = new Keyboard(document)
keyboardRef.current.onkeydown = (keySym) => client?.sendKeyEvent(1, keySym)
keyboardRef.current.onkeyup = (keySym) => client?.sendKeyEvent(0, keySym)
// Release all keys when window loses focus
window.addEventListener('blur', keyboardRef.current?.reset)
return () => {
window.removeEventListener('blur', keyboardRef.current?.reset)
}
}, [isConnected])
return { keyboard: keyboardRef.current }
}
export { GuacamoleKeyboard }

@ -0,0 +1,78 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
// eslint-disable-next-line no-unused-vars
import { useEffect, useRef } from 'react'
import { Mouse } from 'guacamole-common-js'
import { GuacamoleSession } from 'client/constants'
/**
* @typedef GuacamoleMousePlugin
* @property {Mouse} [mouse] - Guacamole mouse
*/
/**
* @param {GuacamoleSession} session - Guacamole session
* @returns {GuacamoleMousePlugin} Guacamole mouse plugin
*/
const GuacamoleMouse = (session) => {
const { client, display, isConnected } = session ?? {}
const mouseRef = useRef(null)
const handleMouseState = (mouseState, scaleMouse = false) => {
// Do not attempt to handle mouse state changes if the client
// or display are not yet available
if (!client) return
if (scaleMouse) {
const clientScale = client.getDisplay().getScale()
mouseState.y = mouseState.y / clientScale
mouseState.x = mouseState.x / clientScale
}
// Send mouse state, show cursor if necessary
// client.display?.showCursor(!localCursor)
client.sendMouseState(mouseState)
}
useEffect(() => {
if (!isConnected) return
const mouse = new Mouse(client.getDisplay().getElement())
mouseRef.current = mouse
mouse.onmousedown = mouse.onmouseup = (mouseState) => {
// Ensure focus is regained via mousedown before forwarding event
display?.focus()
handleMouseState(mouseState)
}
// Forward mousemove events untouched
mouse.onmousemove = (mouseState) => {
handleMouseState(mouseState, true)
}
// Hide software cursor when mouse leaves display
mouse.onmouseout = () => {
// client?.display?.showCursor(false)
}
}, [isConnected])
return { mouse: mouseRef.current }
}
export { GuacamoleMouse }

@ -0,0 +1,41 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useCallback, useRef } from 'react'
/**
* Helps avoid a lot of potential memory leaks.
*
* @param {object} obj - Instance object
* @returns {function():object} - Returns the last object
*/
export const useGetLatest = (obj) => {
const ref = useRef()
ref.current = obj
return useCallback(() => ref.current, [])
}
/**
* Assign the plugin state to the previous state.
*
* @param {object} prevState - Previous state
* @param {function():object} plugin - Plugin
* @returns {object} Returns the new state
*/
export const reducePlugin = (prevState, plugin) => ({
...prevState,
...plugin(prevState),
})

@ -0,0 +1,16 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export * from 'client/components/Consoles/Guacamole'

@ -32,9 +32,9 @@ const TYPE = {
dependOf: '$general.HYPERVISOR',
values: (hypervisor = kvm) => {
const types = {
[vcenter]: [T.VMRC],
[lxc]: [T.VNC],
}[hypervisor] ?? [T.VNC, T.SDL, T.SPICE]
[vcenter]: [T.Vmrc],
[lxc]: [T.Vnc],
}[hypervisor] ?? [T.Vnc, T.Sdl, T.Spice]
return arrayToOptions(types)
},

@ -19,6 +19,7 @@ import PropTypes from 'prop-types'
import { useAuth, useAuthApi } from 'client/features/Auth'
import { authApi } from 'client/features/AuthApi'
import { oneApi } from 'client/features/OneApi'
import groupApi from 'client/features/OneApi/group'
import FullscreenProgress from 'client/components/LoadingScreen'
import { findStorageData } from 'client/utils'
@ -46,6 +47,8 @@ const AuthLayout = ({ subscriptions = [], children }) => {
return () => {
authSubscription.unsubscribe()
dispatch(authApi.util.resetApiState())
dispatch(oneApi.util.resetApiState())
}
}, [dispatch, jwt])

@ -14,10 +14,9 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useRef, useEffect } from 'react'
import { useRef, useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import { useParams } from 'react-router-dom'
import clsx from 'clsx'
import { Box, Container } from '@mui/material'
import { CSSTransition } from 'react-transition-group'
@ -25,8 +24,9 @@ import { useGeneral, useGeneralApi } from 'client/features/General'
import Header from 'client/components/Header'
import Footer from 'client/components/Footer'
import internalStyles from 'client/components/HOC/InternalLayout/styles'
import { sidebar } from 'client/theme/defaults'
const InternalLayout = ({ title, children }) => {
const InternalLayout = ({ title, customHeader, disabledSidebar, children }) => {
const classes = internalStyles()
const container = useRef()
const { isFixMenu } = useGeneral()
@ -40,9 +40,27 @@ const InternalLayout = ({ title, children }) => {
return (
<Box
data-cy="main-layout"
className={clsx(classes.root, { [classes.isDrawerFixed]: isFixMenu })}
className={classes.root}
sx={useMemo(
() =>
disabledSidebar
? {}
: {
marginLeft: {
lg: isFixMenu
? `${sidebar.fixed}px`
: `${sidebar.minified}px`,
},
},
[isFixMenu, disabledSidebar]
)}
>
<Header scrollContainer={container.current} />
{customHeader ?? (
<Header
disabledSidebar={disabledSidebar}
scrollContainer={container.current}
/>
)}
<Box component="main" className={classes.main}>
<CSSTransition
in
@ -75,6 +93,8 @@ const InternalLayout = ({ title, children }) => {
InternalLayout.propTypes = {
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
customHeader: PropTypes.node,
disabledSidebar: PropTypes.bool,
children: PropTypes.any,
}

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import makeStyles from '@mui/styles/makeStyles'
import { sidebar, toolbar, footer } from 'client/theme/defaults'
import { toolbar, footer } from 'client/theme/defaults'
export default makeStyles((theme) => ({
root: {
@ -27,14 +27,6 @@ export default makeStyles((theme) => ({
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
[theme.breakpoints.up('lg')]: {
marginLeft: sidebar.minified,
},
},
isDrawerFixed: {
[theme.breakpoints.up('lg')]: {
marginLeft: sidebar.fixed,
},
},
main: {
height: '100vh',
@ -49,7 +41,6 @@ export default makeStyles((theme) => ({
},
},
scrollable: {
backgroundColor: theme.palette.background.default,
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
height: '100%',

@ -38,6 +38,7 @@ const HeaderPopover = memo(
icon,
buttonLabel,
buttonProps,
onMouseHover,
headerTitle,
popperProps,
children,
@ -78,7 +79,9 @@ const HeaderPopover = memo(
aria-haspopup
aria-describedby={hasId}
aria-expanded={open ? 'true' : 'false'}
onClick={handleClick}
{...(onMouseHover
? { onMouseEnter: handleClick, onMouseLeave: handleClose }
: { onClick: handleClick })}
size="small"
endIcon={<CaretIcon />}
startIcon={icon}
@ -147,6 +150,7 @@ HeaderPopover.propTypes = {
buttonProps: PropTypes.object,
tooltip: PropTypes.any,
headerTitle: PropTypes.any,
onMouseHover: PropTypes.bool,
disablePadding: PropTypes.bool,
popperProps: PropTypes.object,
children: PropTypes.func,
@ -160,6 +164,7 @@ HeaderPopover.defaultProps = {
buttonProps: {},
headerTitle: undefined,
disablePadding: false,
onMouseHover: false,
popperProps: {},
children: () => undefined,
}

@ -36,7 +36,7 @@ import Group from 'client/components/Header/Group'
import Zone from 'client/components/Header/Zone'
import { sentenceCase } from 'client/utils'
const Header = () => {
const Header = ({ disabledSidebar = false }) => {
const { isOneAdmin } = useAuth()
const { fixMenu } = useGeneralApi()
const { appTitle, title, isBeta, withGroupSwitcher } = useGeneral()
@ -45,15 +45,17 @@ const Header = () => {
return (
<AppBar data-cy="header" elevation={0} position="absolute">
<Toolbar>
<IconButton
onClick={() => fixMenu(true)}
edge="start"
size="small"
variant="outlined"
sx={{ display: { lg: 'none' } }}
>
<MenuIcon />
</IconButton>
{!disabledSidebar && (
<IconButton
onClick={() => fixMenu(true)}
edge="start"
size="small"
variant="outlined"
sx={{ display: { lg: 'none' } }}
>
<MenuIcon />
</IconButton>
)}
<Box
flexGrow={1}
ml={2}
@ -82,20 +84,22 @@ const Header = () => {
</Typography>
)}
</Typography>
<Typography
variant="h6"
data-cy="header-description"
sx={{
display: { xs: 'none', xl: 'block' },
'&::before': {
content: '"|"',
margin: '0.5em',
color: 'primary.contrastText',
},
}}
>
{title}
</Typography>
{title && (
<Typography
variant="h6"
data-cy="header-description"
sx={{
display: { xs: 'none', xl: 'block' },
'&::before': {
content: '"|"',
margin: '0.5em',
color: 'primary.contrastText',
},
}}
>
{title}
</Typography>
)}
</Box>
<Stack
direction="row"
@ -113,11 +117,8 @@ const Header = () => {
}
Header.propTypes = {
disabledSidebar: PropTypes.bool,
scrollContainer: PropTypes.object,
}
Header.defaultProps = {
scrollContainer: null,
}
export default Header

@ -16,6 +16,7 @@
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useLocation, matchPath } from 'react-router'
import clsx from 'clsx'
import {
@ -41,6 +42,7 @@ import SidebarLink from 'client/components/Sidebar/SidebarLink'
import SidebarCollapseItem from 'client/components/Sidebar/SidebarCollapseItem'
const Sidebar = ({ endpoints }) => {
const { pathname } = useLocation()
const classes = sidebarStyles()
const isUpLg = useMediaQuery((theme) => theme.breakpoints.up('lg'), {
noSsr: true,
@ -66,6 +68,18 @@ const Sidebar = ({ endpoints }) => {
[endpoints]
)
const isDisabledSidebar = useMemo(() => {
const endpoint = endpoints.find(({ path }) =>
matchPath(pathname, { path, exact: true })
)
return endpoint?.disabledSidebar
}, [pathname])
if (isDisabledSidebar) {
return null
}
return (
<Drawer
variant="permanent"

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useEffect, useMemo, ReactElement } from 'react'
import { useMemo, ReactElement } from 'react'
import { useViews } from 'client/features/Auth'
import { useGetVmsQuery } from 'client/features/OneApi/vm'
@ -23,15 +23,6 @@ import VmColumns from 'client/components/Tables/Vms/columns'
import VmRow from 'client/components/Tables/Vms/row'
import { RESOURCE_NAMES } from 'client/constants'
const INITIAL_ELEMENT = 0
const INTERVAL_ON_FIRST_RENDER = 2_000
const INITIAL_ARGS = {
start: INITIAL_ELEMENT,
end: -INTERVAL_ON_FIRST_RENDER,
state: -1,
}
const DEFAULT_DATA_CY = 'vms'
/**
@ -54,10 +45,11 @@ const VmsTable = (props) => {
)
const { view, getResourceView } = useViews()
const [totalData, setTotalData] = useState(() => [])
const [args, setArgs] = useState(() => INITIAL_ARGS)
const { data, isSuccess, refetch, isFetching } = useGetVmsQuery(args, {
refetchOnMountOrArgChange: true,
const { data, refetch, isFetching } = useGetVmsQuery(undefined, {
selectFromResult: (result) => ({
...result,
data: result?.data?.filter(({ STATE }) => STATE !== '6') ?? [],
}),
})
const columns = useMemo(
@ -69,41 +61,13 @@ const VmsTable = (props) => {
[view]
)
useEffect(() => {
if (!isFetching && isSuccess && data?.length >= +INTERVAL_ON_FIRST_RENDER) {
setArgs((prev) => ({
...prev,
start: prev.start + INTERVAL_ON_FIRST_RENDER,
}))
}
}, [isFetching])
useEffect(() => {
isSuccess &&
data &&
setTotalData((prev) => {
const notDuplicatedData = data.filter(
({ ID }) => !prev.find((vm) => vm.ID === ID)
)
return prev.concat(notDuplicatedData).sort((a, b) => b.ID - a.ID)
})
}, [isSuccess])
return (
<EnhancedTable
columns={columns}
data={useMemo(
() => totalData?.filter(({ STATE }) => STATE !== '6'),
[totalData]
)}
data={useMemo(() => data, [data])}
rootProps={rootProps}
searchProps={searchProps}
refetch={() => {
totalData?.length >= +INTERVAL_ON_FIRST_RENDER
? setArgs(INITIAL_ARGS)
: refetch()
}}
refetch={refetch}
isLoading={isFetching}
getRowId={(row) => String(row.ID)}
RowComponent={VmRow}

@ -13,18 +13,42 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import vmApi from 'client/features/OneApi/vm'
import { VirtualMachineCard } from 'client/components/Cards'
import { GuacamoleButton } from 'client/components/Buttons'
import { VM_ACTIONS } from 'client/constants'
const { VNC, RDP, SSH } = VM_ACTIONS
const Row = memo(
({ original, ...props }) => {
const detail = vmApi.endpoints.getVm.useQueryState(original.ID, {
selectFromResult: ({ data }) => data,
const state = vmApi.endpoints.getVms.useQueryState(undefined, {
selectFromResult: ({ data = [] }) =>
data.find((vm) => +vm.ID === +original.ID),
})
return <VirtualMachineCard vm={detail ?? original} rootProps={props} />
const memoVm = useMemo(() => state ?? original, [state, original])
return (
<VirtualMachineCard
vm={memoVm}
rootProps={props}
actions={
<>
{[VNC, RDP, SSH].map((connectionType) => (
<GuacamoleButton
key={`${memoVm}-${connectionType}`}
connectionType={connectionType}
vm={memoVm}
/>
))}
</>
}
/>
)
},
(prev, next) => prev.className === next.className
)

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetClusterQuery } from 'client/features/OneApi/cluster'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const ClusterTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetClusterQuery({ id })
const { isLoading, isError, error } = useGetClusterQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.CLUSTER
@ -41,6 +41,14 @@ const ClusterTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetDatastoreQuery } from 'client/features/OneApi/datastore'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const DatastoreTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetDatastoreQuery({ id })
const { isLoading, isError, error } = useGetDatastoreQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.DATASTORE
@ -41,6 +41,14 @@ const DatastoreTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetGroupQuery } from 'client/features/OneApi/group'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const GroupTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetGroupQuery(id)
const { isLoading, isError, error } = useGetGroupQuery(id)
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.GROUP
@ -41,6 +41,14 @@ const GroupTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useAuth } from 'client/features/Auth'
import { useGetHostQuery } from 'client/features/OneApi/host'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const HostTabs = memo(({ id }) => {
const { view, getResourceView } = useAuth()
const { isLoading } = useGetHostQuery(id)
const { isLoading, isError, error } = useGetHostQuery(id)
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.HOST
@ -41,6 +41,14 @@ const HostTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetImageQuery } from 'client/features/OneApi/image'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const ImageTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetImageQuery({ id })
const { isLoading, isError, error } = useGetImageQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.IMAGE
@ -41,6 +41,14 @@ const ImageTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetMarketplaceQuery } from 'client/features/OneApi/marketplace'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const MarketplaceTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetMarketplaceQuery({ id })
const { isLoading, isError, error } = useGetMarketplaceQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.MARKETPLACE
@ -41,6 +41,14 @@ const MarketplaceTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetMarketplaceAppQuery } from 'client/features/OneApi/marketplaceApp'
@ -34,7 +34,7 @@ const getTabComponent = (tabName) =>
const MarketplaceAppTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetMarketplaceAppQuery(id)
const { isLoading, isError, error } = useGetMarketplaceAppQuery(id)
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.APP
@ -43,6 +43,14 @@ const MarketplaceAppTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetUserQuery } from 'client/features/OneApi/user'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const UserTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetUserQuery(id)
const { isLoading, isError, error } = useGetUserQuery(id)
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.USER
@ -41,6 +41,14 @@ const UserTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetVNetworkQuery } from 'client/features/OneApi/network'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const VNetworkTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetVNetworkQuery({ id })
const { isLoading, isError, error } = useGetVNetworkQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.VNET
@ -41,6 +41,14 @@ const VNetworkTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetVNTemplateQuery } from 'client/features/OneApi/networkTemplate'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const VNetTemplateTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetVNTemplateQuery({ id })
const { isLoading, isError, error } = useGetVNTemplateQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.VN_TEMPLATE
@ -41,6 +41,14 @@ const VNetTemplateTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetVmQuery } from 'client/features/OneApi/vm'
@ -46,7 +46,7 @@ const getTabComponent = (tabName) =>
const VmTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetVmQuery(id, {
const { isLoading, isError, error } = useGetVmQuery(id, {
refetchOnMountOrArgChange: 10,
})
@ -57,6 +57,14 @@ const VmTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetTemplateQuery } from 'client/features/OneApi/vmTemplate'
@ -34,7 +34,7 @@ const getTabComponent = (tabName) =>
const VmTemplateTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetTemplateQuery({ id })
const { isLoading, isError, error } = useGetTemplateQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.VM_TEMPLATE
@ -43,6 +43,14 @@ const VmTemplateTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { LinearProgress } from '@mui/material'
import { Alert, LinearProgress } from '@mui/material'
import { useViews } from 'client/features/Auth'
import { useGetZoneQuery } from 'client/features/OneApi/zone'
@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const ZoneTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading } = useGetZoneQuery(id)
const { isLoading, isError, error } = useGetZoneQuery(id)
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.ZONE
@ -41,6 +41,14 @@ const ZoneTabs = memo(({ id }) => {
return getAvailableInfoTabs(infoTabs, getTabComponent, id)
}, [view])
if (isError) {
return (
<Alert severity="error" variant="outlined">
{error.data}
</Alert>
)
}
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (

@ -0,0 +1,101 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
// eslint-disable-next-line no-unused-vars
import { Client, WebSocketTunnel, Status } from 'guacamole-common-js'
/**
* @typedef GuacamoleSessionThumbnail
* @property {number} timestamp - The time that this thumbnail was generated
* @property {HTMLCanvasElement} canvas - The thumbnail of the Guacamole client display
*/
/**
* @typedef GuacamoleSessionProperties
* @property {boolean} autoFit - Whether the display should be scaled automatically
* @property {number} scale - The current scale.
* If autoFit is true, the effect of setting this value is undefined
* @property {number} minScale - The minimum scale value
* @property {number} maxScale - The maximum scale value
* @property {boolean} keyboardEnabled - Whether or not the client should listen to keyboard events
* @property {number} emulateAbsoluteMouse - Whether translation of touch to mouse events should
* emulate an absolute pointer device, or a relative pointer device
* @property {number} scrollTop - The relative Y coordinate of the scroll offset of the display
* @property {number} scrollLeft - The relative X coordinate of the scroll offset of the display
*/
/**
* @typedef GuacamoleSessionState
* @property {GUACAMOLE_CLIENT_STATES} connectionState - The current connection state
* @property {Status.Code} statusCode - The status code of the current error condition
* @property {boolean} tunnelUnstable - Whether the network connection used by the tunnel seems unstable
*/
/**
* @typedef GuacamoleSession
* @property {string} token - The token of the connection associated with this client
* @property {string} name - The name returned associated with the connection or connection group in use
* @property {string} title - The title which should be displayed as the page title for this client
* @property {Client} client - The actual underlying Guacamole client
* @property {WebSocketTunnel} tunnel - The tunnel being used by the underlying Guacamole client
* @property {GuacamoleSessionState} clientState - The current state of the Guacamole client
* @property {boolean} isUninitialized - When true, indicates that the session hasn't been fired yet
* @property {boolean} isLoading - When true, indicates that the session is awaiting a response
* @property {boolean} isConnected - When true, indicates that the last session was connected successfully
* @property {boolean} isDisconnected - When true, indicates that the last session was disconnected
* @property {boolean} isError - When true, indicates that the last session has an error state
* @property {GuacamoleSessionProperties} clientProperties - The current state of the Guacamole client
* @property {GuacamoleSessionThumbnail} thumbnail - The most recently-generated thumbnail for this connection
* @property {number} multiTouchSupport - The number of simultaneous touch contacts supported
*/
/** @enum {string} Guacamole client state strings */
export const GUACAMOLE_STATES_STR = {
IDLE: 'IDLE',
CONNECTING: 'CONNECTING',
WAITING: 'WAITING',
CONNECTED: 'CONNECTED',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
CLIENT_ERROR: 'CLIENT_ERROR',
TUNNEL_ERROR: 'TUNNEL_ERROR',
}
/** @enum {string} Guacamole client states */
export const GUACAMOLE_CLIENT_STATES = [
GUACAMOLE_STATES_STR.IDLE,
GUACAMOLE_STATES_STR.CONNECTING,
GUACAMOLE_STATES_STR.WAITING,
GUACAMOLE_STATES_STR.CONNECTED,
GUACAMOLE_STATES_STR.DISCONNECTING,
GUACAMOLE_STATES_STR.DISCONNECTED,
]
/**
* The mimetype of audio data to be sent along the Guacamole
* connection if audio input is supported.
*
* @type {string}
*/
export const AUDIO_INPUT_MIMETYPE = 'audio/L16;rate=44100,channels=2'
/**
* The minimum amount of time to wait between updates to
* the client thumbnail, in milliseconds.
*
* @type {number}
*/
export const THUMBNAIL_UPDATE_FREQUENCY = 5000

@ -27,7 +27,7 @@ export const BY = {
url: 'https://opennebula.io/',
}
export const _APPS = defaultApps
export const _APPS = { ...defaultApps }
export const APPS = Object.keys(defaultApps)
export const APPS_IN_BETA = [_APPS.sunstone.name]
export const APPS_WITH_SWITCHER = [_APPS.sunstone.name]
@ -90,6 +90,7 @@ export const SOCKETS = {
DISCONNECT: 'disconnect',
HOOKS: 'hooks',
PROVISION: 'provision',
GUACAMOLE: 'guacamole',
}
/** @enum {string} Names of resource */
@ -114,23 +115,24 @@ export const RESOURCE_NAMES = {
export * as T from 'client/constants/translates'
export * as ACTIONS from 'client/constants/actions'
export * as STATES from 'client/constants/states'
export * from 'client/constants/common'
export * from 'client/constants/quota'
export * from 'client/constants/scheduler'
export * from 'client/constants/userInput'
export * from 'client/constants/flow'
export * from 'client/constants/provision'
export * from 'client/constants/user'
export * from 'client/constants/group'
export * from 'client/constants/cluster'
export * from 'client/constants/vm'
export * from 'client/constants/vmTemplate'
export * from 'client/constants/network'
export * from 'client/constants/networkTemplate'
export * from 'client/constants/common'
export * from 'client/constants/datastore'
export * from 'client/constants/flow'
export * from 'client/constants/group'
export * from 'client/constants/guacamole'
export * from 'client/constants/host'
export * from 'client/constants/image'
export * from 'client/constants/marketplace'
export * from 'client/constants/marketplaceApp'
export * from 'client/constants/datastore'
export * from 'client/constants/network'
export * from 'client/constants/networkTemplate'
export * from 'client/constants/provision'
export * from 'client/constants/quota'
export * from 'client/constants/scheduler'
export * from 'client/constants/securityGroup'
export * from 'client/constants/user'
export * from 'client/constants/userInput'
export * from 'client/constants/vm'
export * from 'client/constants/vmTemplate'
export * from 'client/constants/zone'

@ -361,6 +361,19 @@ module.exports = {
ReadyStatusGate: 'Ready status gate',
/* VM schema */
/* VM schema - remote access */
Vnc: 'VNC',
Ssh: 'SSH',
Rdp: 'RDP',
SshConnection: 'SSH connection',
RdpConnection: 'RDP connection',
Vmrc: 'VMRC',
Sdl: 'SDL',
Spice: 'SPICE',
SendCtrlAltDel: 'Send Ctrl-Alt-Del',
Reconnect: 'Reconnect',
FullScreen: 'Full screen',
Screenshot: 'Screenshot',
/* VM schema - info */
UserTemplate: 'User Template',
Template: 'Template',
@ -389,8 +402,6 @@ module.exports = {
NIC: 'NIC',
Alias: 'Alias',
AsAnAlias: 'Attach as an alias',
RdpConnection: 'RDP connection',
SshConnection: 'SSH connection',
External: 'External',
ExternalConcept: 'The NIC will be attached as an external alias of the VM',
OverrideNetworkValuesIPv4: 'Override Network Values IPv4',
@ -568,10 +579,6 @@ module.exports = {
Class: 'Class',
/* VM Template schema - Input/Output - graphics */
Graphics: 'Graphics',
VMRC: 'VMRC',
VNC: 'VNC',
SDL: 'SDL',
SPICE: 'SPICE',
ListenOnIp: 'Listen on IP',
ServerPort: 'Server port',
ServerPortConcept: 'Port for the VNC/SPICE server',

@ -873,13 +873,12 @@ export const VM_ACTIONS_BY_STATE = {
[VM_ACTIONS.UNRESCHED]: [STATES.RUNNING, STATES.UNKNOWN],
// REMOTE
[VM_ACTIONS.VMRC]: [],
[VM_ACTIONS.SPICE]: [],
[VM_ACTIONS.VNC]: [],
[VM_ACTIONS.SSH]: [],
[VM_ACTIONS.RDP]: [],
[VM_ACTIONS.FILE_RDP]: [],
[VM_ACTIONS.FILE_VIRT_VIEWER]: [],
[VM_ACTIONS.VMRC]: [STATES.RUNNING],
[VM_ACTIONS.VNC]: [STATES.RUNNING],
[VM_ACTIONS.SSH]: [STATES.RUNNING],
[VM_ACTIONS.RDP]: [STATES.RUNNING],
[VM_ACTIONS.FILE_RDP]: [STATES.RUNNING],
[VM_ACTIONS.FILE_VIRT_VIEWER]: [STATES.RUNNING],
// INFORMATION
[VM_ACTIONS.RENAME]: [],

@ -0,0 +1,152 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo, useRef, useEffect } from 'react'
import { useParams, useHistory } from 'react-router'
import { Box, Stack, Typography, Divider, Skeleton } from '@mui/material'
import { useGetVmQuery } from 'client/features/OneApi/vm'
import {
useGuacamoleSession,
GuacamoleDisplay,
GuacamoleKeyboard,
GuacamoleMouse,
GuacamoleClipboard,
GuacamoleCtrlAltDelButton,
GuacamoleReconnectButton,
GuacamoleFullScreenButton,
GuacamoleScreenshotButton,
} from 'client/components/Consoles'
import { useViews } from 'client/features/Auth'
import { StatusCircle } from 'client/components/Status'
import MultipleTags from 'client/components/MultipleTags'
import { getIps, getState } from 'client/models/VirtualMachine'
import { timeFromMilliseconds } from 'client/models/Helper'
import { PATH } from 'client/apps/sunstone/routes'
import { RESOURCE_NAMES } from 'client/constants'
/** @returns {ReactElement} Guacamole container */
const Guacamole = () => {
const { id, type = '' } = useParams()
const { push: redirectTo } = useHistory()
const { view, [RESOURCE_NAMES.VM]: vmView } = useViews()
const containerRef = useRef(null)
const headerRef = useRef(null)
const { data: vm, isLoading, isError } = useGetVmQuery(id)
const ips = getIps(vm)
const { color: stateColor, name: stateName } = getState(vm) ?? {}
const time = timeFromMilliseconds(+vm?.ETIME || +vm?.STIME)
const { token, clientState, displayElement, ...session } =
useGuacamoleSession(
useMemo(
() => ({
id: `${id}-${type}`,
container: containerRef.current,
header: headerRef.current,
}),
[
containerRef.current?.offsetWidth,
containerRef.current?.offsetHeight,
headerRef.current?.offsetWidth,
headerRef.current?.offsetHeight,
]
),
GuacamoleDisplay,
GuacamoleMouse,
GuacamoleKeyboard,
GuacamoleClipboard
)
useEffect(() => {
const noAction = vmView?.actions?.[type] !== true
// token should be saved after click on console button from datatable
if (noAction || !token || isError) {
redirectTo(PATH.DASHBOARD)
}
}, [view, token])
return (
<Box
ref={containerRef}
sx={{
height: '100%',
display: 'grid',
gridTemplateRows: 'auto 1fr',
}}
>
<Stack ref={headerRef}>
<Stack direction="row" justifyContent="space-between" gap="1em" px={2}>
<Typography
flexGrow={1}
display="flex"
alignItems="center"
gap="0.5em"
>
{isLoading ? (
<>
<Skeleton variant="circular" width={12} height={12} />
<Skeleton variant="text" width="60%" />
</>
) : (
<>
<StatusCircle color={stateColor} tooltip={stateName} />
{`# ${vm?.ID} - ${vm?.NAME}`}
</>
)}
</Typography>
<Stack
flexGrow={1}
direction="row"
justifyContent="flex-end"
divider={<Divider orientation="vertical" flexItem />}
gap="1em"
>
{isLoading ? (
<Skeleton variant="text" width="60%" />
) : (
<Typography>{`Started on: ${time.toFormat('ff')}`}</Typography>
)}
{isLoading ? (
<Skeleton variant="text" width="40%" />
) : (
!!ips?.length && (
<Typography>
<MultipleTags tags={ips} />
</Typography>
)
)}
</Stack>
</Stack>
<Stack direction="row" alignItems="center" gap="1em" my="1em">
<GuacamoleCtrlAltDelButton {...session} />
<GuacamoleReconnectButton {...session} />
<GuacamoleScreenshotButton {...session} />
<GuacamoleFullScreenButton {...session} />
{clientState?.connectionState && (
<Typography>{`State: ${clientState?.connectionState}`}</Typography>
)}
</Stack>
</Stack>
{displayElement}
</Box>
)
}
export default Guacamole

@ -112,5 +112,19 @@ export const useViews = () => {
[view]
)
return useMemo(() => ({ getResourceView, views, view }), [views, view])
return useMemo(
() => ({
...Object.values(RESOURCE_NAMES).reduce(
(listOfResourceViews, resourceName) => ({
...listOfResourceViews,
[resourceName]: getResourceView(resourceName),
}),
{}
),
getResourceView,
views,
view,
}),
[views, view]
)
}

@ -0,0 +1,73 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useCallback, useMemo } from 'react'
import { useDispatch, useSelector, shallowEqual } from 'react-redux'
import { name as guacSlice, actions } from 'client/features/Guacamole/slice'
import { GuacamoleSession } from 'client/constants'
const {
addGuacamoleSession,
removeGuacamoleSession,
updateThumbnail,
setConnectionState,
setTunnelUnstable,
setMultiTouchSupport,
} = actions
// --------------------------------------------------------------
// Guacamole Hooks
// --------------------------------------------------------------
/**
* Hook to get the state of Guacamole sessions.
*
* @param {string} [id] - Session id to subscribe
* @returns {object|GuacamoleSession} Return Guacamole session by id or global state
*/
export const useGuacamole = (id) => {
const guac = useSelector(
(state) => (id ? state[guacSlice][id] : state[guacSlice]),
shallowEqual
)
return useMemo(() => ({ ...guac }), [guac])
}
/**
* Hook to manage Guacamole sessions.
*
* @param {string} [id] - Session id to operate
* @returns {object} Return management actions
*/
export const useGuacamoleApi = (id) => {
const dispatch = useDispatch()
const commonDispatch = useCallback(
(action) => (data) => dispatch(action({ id, ...data })),
[dispatch, id]
)
return {
addSession: commonDispatch(addGuacamoleSession),
removeSession: commonDispatch(removeGuacamoleSession),
updateThumbnail: commonDispatch(updateThumbnail),
setConnectionState: commonDispatch(setConnectionState),
setTunnelUnstable: commonDispatch(setTunnelUnstable),
setMultiTouchSupport: commonDispatch(setMultiTouchSupport),
}
}

@ -0,0 +1,17 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export * from 'client/features/Guacamole/slice'
export * from 'client/features/Guacamole/hooks'

@ -0,0 +1,126 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { createSlice } from '@reduxjs/toolkit'
import { Status } from 'guacamole-common-js'
import { actions as authActions } from 'client/features/Auth/slice'
import { GUACAMOLE_STATES_STR } from 'client/constants'
const {
IDLE,
CONNECTING,
WAITING,
CONNECTED,
DISCONNECTING,
DISCONNECTED,
CLIENT_ERROR,
TUNNEL_ERROR,
} = GUACAMOLE_STATES_STR
const getIdentifiedFromPayload = ({ id, type } = {}) =>
id?.includes('-') ? id : `${id}-${type}`
const INITIAL_SESSION = {
thumbnail: null,
multiTouchSupport: 0,
clientState: {
connectionState: IDLE,
tunnelUnstable: false,
statusCode: Status.Code.SUCCESS,
},
isUninitialized: true,
isLoading: false,
isConnected: false,
isDisconnected: false,
isError: false,
clientProperties: {
autoFit: true,
scale: 1,
minScale: 1,
maxScale: 3,
focused: false,
scrollTop: 0,
scrollLeft: 0,
},
}
const slice = createSlice({
name: 'guacamole',
initialState: {},
reducers: {
addGuacamoleSession: (state, { payload }) => {
const id = getIdentifiedFromPayload(payload)
state[id] = { ...INITIAL_SESSION, token: payload?.token }
},
removeGuacamoleSession: (state, { payload }) => {
const id = getIdentifiedFromPayload(payload)
const { [id]: _, ...rest } = state
return { ...rest }
},
updateGuacamoleSession: (state, { payload }) => {
const id = getIdentifiedFromPayload(payload)
const { [id]: session = {} } = state
state[id] = { ...session, ...payload?.session }
},
setConnectionState: (state, { payload = {} }) => {
const { state: cState, statusCode } = payload
const id = getIdentifiedFromPayload(payload)
const { [id]: session = {} } = state
if (
!session ||
session?.clientState.connectionState === TUNNEL_ERROR ||
session?.clientState.connectionState === CLIENT_ERROR
)
return state
statusCode && (session.clientState.statusCode = statusCode)
session.clientState.connectionState = cState
session.clientState.tunnelUnstable = false
session.isUninitialized = cState === IDLE
session.isLoading = [WAITING, CONNECTING, DISCONNECTING].includes(cState)
session.isConnected = cState === CONNECTED
session.isDisconnected = cState === DISCONNECTED
session.isError = [CLIENT_ERROR, TUNNEL_ERROR].includes(cState)
},
setTunnelUnstable: (state, { payload = {} }) => {
const id = getIdentifiedFromPayload(payload)
const { [id]: session = {} } = state
session.clientState.tunnelUnstable = payload.unstable
},
setMultiTouchSupport: (state, { payload = {} }) => {
const id = getIdentifiedFromPayload(payload)
const { [id]: session = {} } = state
state[id] = { ...session, multiTouchSupport: payload?.touches }
},
updateThumbnail: (state, { payload = {} }) => {
const id = getIdentifiedFromPayload(payload)
const { [id]: session = {} } = state
state[id] = { ...session, thumbnail: payload?.thumbnail }
},
},
extraReducers: (builder) => {
builder.addCase(authActions.logout, () => ({}))
},
})
export const { name, reducer, actions } = slice

@ -14,11 +14,16 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Actions, Commands } from 'server/utils/constants/commands/vm'
import {
Actions as ExtraActions,
Commands as ExtraCommands,
} from 'server/routes/api/vm/routes'
import {
oneApi,
ONE_RESOURCES,
ONE_RESOURCES_POOL,
} from 'client/features/OneApi'
import { actions as guacamoleActions } from 'client/features/Guacamole/slice'
import { UpdateFromSocket } from 'client/features/OneApi/socket'
import http from 'client/utils/rest'
import {
@ -101,7 +106,13 @@ const vmApi = oneApi.injectEndpoints({
index !== -1 && (draft[index] = queryVm)
})
)
} catch {}
} catch {
dispatch(
vmApi.util.updateQueryData('getVms', undefined, (draft) =>
draft.filter(({ ID }) => +ID !== +id)
)
)
}
},
onCacheEntryAdded: UpdateFromSocket({
updateQueryData: (updateFn) =>
@ -109,6 +120,29 @@ const vmApi = oneApi.injectEndpoints({
resource: VM.toLowerCase(),
}),
}),
getGuacamoleSession: builder.query({
/**
* Returns a Guacamole session.
*
* @param {object} params - Request parameters
* @param {string} params.id - Virtual machine id
* @param {'vnc'|'ssh'|'rdp'} params.type - Connection type
* @returns {string} The session token
* @throws Fails when response isn't code 200
*/
query: (params) => {
const name = ExtraActions.GUACAMOLE
const command = { name, ...ExtraCommands[name] }
return { params, command }
},
async onQueryStarted({ id, type }, { dispatch, queryFulfilled }) {
try {
const { data: token } = await queryFulfilled
dispatch(guacamoleActions.addGuacamoleSession({ id, type, token }))
} catch {}
},
}),
getMonitoring: builder.query({
/**
* Returns the virtual machine monitoring records.
@ -535,6 +569,34 @@ const vmApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: VM, id }],
async onQueryStarted(
{ id, ...permissions },
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
vmApi.util.updateQueryData('getVm', id, (draft) => {
Object.entries(permissions)
.filter(([_, value]) => value !== '-1')
.forEach(([name, value]) => {
const ensuredName = {
ownerUse: 'OWNER_U',
ownerManage: 'OWNER_M',
ownerAdmin: 'OWNER_A',
groupUse: 'GROUP_U',
groupManage: 'GROUP_M',
groupAdmin: 'GROUP_A',
otherUse: 'OTHER_U',
otherManage: 'OTHER_M',
otherAdmin: 'OTHER_A',
}[name]
draft.PERMISSIONS[ensuredName] = value
})
})
)
queryFulfilled.catch(patchResult.undo)
},
}),
changeVmOwnership: builder.mutation({
/**
@ -844,6 +906,8 @@ export const {
useLazyGetVmsQuery,
useGetVmQuery,
useLazyGetVmQuery,
useGetGuacamoleSessionQuery,
useLazyGetGuacamoleSessionQuery,
useGetMonitoringQuery,
useLazyGetMonitoringQuery,
useGetMonitoringPoolQuery,

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { isRejectedWithValue, Middleware, Dispatch } from '@reduxjs/toolkit'
import * as Auth from 'client/features/Auth/slice'
import { name as authName, logout } from 'client/features/Auth/slice'
import { T, ONEADMIN_GROUP_ID } from 'client/constants'
/**
@ -27,7 +27,7 @@ export const unauthenticatedMiddleware =
(next) =>
(action) => {
if (isRejectedWithValue(action) && action.payload.status === 401) {
dispatch(Auth.actions.logout(T.SessionExpired))
dispatch(logout(T.SessionExpired))
}
return next(action)
@ -41,13 +41,13 @@ export const onlyForOneadminMiddleware =
({ dispatch, getState }) =>
(next) =>
(action) => {
const groups = getState()?.[Auth.name]?.user?.GROUPS?.ID
const groups = getState()?.[authName]?.user?.GROUPS?.ID
if (!Auth.actions.logout.match(action) && groups) {
if (!logout.match(action) && !!groups?.length) {
const ensuredGroups = [groups].flat()
!ensuredGroups.includes(ONEADMIN_GROUP_ID) &&
dispatch(Auth.actions.logout(T.OnlyForOneadminGroup))
dispatch(logout(T.OnlyForOneadminGroup))
}
return next(action)

@ -0,0 +1,56 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { GUACAMOLE_CLIENT_STATES } from 'client/constants'
const isWindow = (display) => display instanceof Window
/**
* @param {number} clientState - Guacamole client state
* @returns {string} State information from resource
*/
export const clientStateToString = (clientState) =>
GUACAMOLE_CLIENT_STATES[+clientState]
/**
* Returns the string of connection parameters
* to be passed to the Guacamole client.
*
* @param {object} options - Connection parameters
* @param {HTMLElement} options.display - Element where the connection will be displayed
* @param {number} options.width - Forced width connection
* @param {number} options.height - Forced height connection
* @returns {string} A string of connection parameters
*/
export const getConnectString = (options = {}) => {
const { token, display = window, dpi, width, height } = options
// Calculate optimal width/height for display
const pixelDensity = window.devicePixelRatio || 1
const optimalDpi = dpi || pixelDensity * 96
const displayWidth =
width || (isWindow(display) ? display?.innerWidth : display?.offsetWidth)
const displayHeight =
height || (isWindow(display) ? display?.innerHeight : display?.offsetHeight)
return [
`token=${encodeURIComponent(token)}`,
`width=${Math.floor(displayWidth * pixelDensity)}`,
`height=${Math.floor(displayHeight * pixelDensity)}`,
`dpi=${Math.floor(optimalDpi)}`,
].join('&')
}

@ -18,6 +18,7 @@ import {
prettySecurityGroup,
} from 'client/models/SecurityGroup'
import { isRelative } from 'client/models/Scheduler'
import { stringToBoolean } from 'client/models/Helper'
import {
STATES,
@ -286,3 +287,16 @@ export const isAvailableAction =
(state) => !VM_ACTIONS_BY_STATE[action]?.includes(state)
)
}
/**
* @param {VM} vm - Virtual machine
* @param {'ssh'|'rdp'} type - Connection type
* @returns {boolean} - Returns connection type is available
*/
export const nicsIncludesTheConnectionType = (vm, type) => {
const ensuredConnection = String(type).toUpperCase()
if (!['SSH', 'RDP'].includes(ensuredConnection)) return false
return getNics(vm).some((nic) => stringToBoolean(nic[ensuredConnection]))
}

@ -28,9 +28,9 @@ import {
import { ProtectedRoute, NoAuthRoute } from 'client/components/Route'
import { InternalLayout } from 'client/components/HOC'
const renderRoute = ({ Component, label, ...rest }, index) => (
const renderRoute = ({ Component, label, disabledSidebar, ...rest }, index) => (
<ProtectedRoute key={index} exact {...rest}>
<InternalLayout title={label}>
<InternalLayout title={label} disabledSidebar={disabledSidebar}>
<Component fallback={<LinearProgress color="secondary" />} />
</InternalLayout>
</ProtectedRoute>
@ -72,7 +72,7 @@ Router.propTypes = {
PropTypes.shape({
Component: PropTypes.object,
icon: PropTypes.object,
label: PropTypes.string.isRequired,
label: PropTypes.string,
path: PropTypes.string,
sidebar: PropTypes.bool,
routes: PropTypes.array,

@ -20,6 +20,7 @@ import { isDevelopment } from 'client/utils'
import * as Auth from 'client/features/Auth/slice'
import * as General from 'client/features/General/slice'
import * as Guacamole from 'client/features/Guacamole/slice'
import { authApi } from 'client/features/AuthApi'
import { oneApi } from 'client/features/OneApi'
import { unauthenticatedMiddleware } from 'client/features/middleware'
@ -35,6 +36,7 @@ export const createStore = ({ initState = {}, extraMiddleware = [] }) => {
reducer: {
[Auth.name]: Auth.reducer,
[General.name]: General.reducer,
[Guacamole.name]: Guacamole.reducer,
[authApi.reducerPath]: authApi.reducer,
[oneApi.reducerPath]: oneApi.reducer,
},

@ -90,6 +90,32 @@ export const encodeBase64 = (string, defaultValue = '') => {
}
}
/**
* Generates a link to download the file, then remove it.
*
* @param {File} file - File
*/
export const downloadFile = (file) => {
try {
// Create a link and set the URL using `createObjectURL`
const link = document.createElement('a')
link.style.display = 'none'
link.href = URL.createObjectURL(file)
link.download = file.name
// It needs to be added to the DOM so it can be clicked
document.body.appendChild(link)
link.click()
// To make this work on Firefox we need to wait
// a little while before removing it
setTimeout(() => {
URL.revokeObjectURL(link.href)
link.parentNode.removeChild(link)
}, 0)
} catch (e) {}
}
/**
* Converts a long string of units into a readable format e.g KB, MB, GB, TB, YB.
*

@ -129,7 +129,7 @@ frontApps.forEach((frontApp) => {
app.get(`${basename}/${frontApp}`, entrypointApp)
app.get(`${basename}/${frontApp}/*`, entrypointApp)
})
app.get('/*', (req, res) => res.redirect(`/${defaultAppName}/provision`))
app.get('/*', (req, res) => res.redirect(`/${defaultAppName}/sunstone`))
// 404 - public
app.get('*', entrypoint404)

@ -406,14 +406,19 @@ const setZones = () => {
* Create token server admin.
*
* @param {object} config - config create token serveradmin
* @param {string} config.serverAdmin - serverAdmin username
* @param {string} config.username - user name
* @param {string} config.key - serverAdmin key
* @param {string} config.iv - serverAdmin iv
* @param {string} config.serverAdmin - serverAdmin username
* @returns {object|undefined} data encrypted serveradmin
*/
const createTokenServerAdmin = ({ serverAdmin, username, key, iv }) => {
if (serverAdmin && username && key && iv) {
const createTokenServerAdmin = ({
username,
key,
iv,
serverAdmin = username,
}) => {
if (username && key && iv) {
!(expireTime && typeof expireTime.toSeconds === 'function') && setDates()
const expire = parseInt(expireTime.toSeconds(), 10)

@ -13,14 +13,25 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
const { randomBytes, createCipheriv } = require('crypto')
const { defaults, httpCodes } = require('server/utils/constants')
const { httpResponse, executeCommand } = require('server/utils/server')
const {
httpResponse,
executeCommand,
getSunstoneAuth,
} = require('server/utils/server')
const { getSunstoneConfig } = require('server/utils/yml')
const { Actions: userActions } = require('server/utils/constants/commands/user')
const { Actions: vmActions } = require('server/utils/constants/commands/vm')
const { createTokenServerAdmin } = require('server/routes/api/auth/utils')
const { defaultEmptyFunction, defaultCommandVM } = defaults
const { USER_INFO } = userActions
const { VM_INFO } = vmActions
const { ok, unauthorized, internalServerError, badRequest } = httpCodes
const { defaultEmptyFunction, defaultCommandVM, defaultTypeCrypto } = defaults
const { ok, internalServerError, badRequest } = httpCodes
const httpBadRequest = httpResponse(badRequest, '', '')
const appConfig = getSunstoneConfig()
const prependCommand = appConfig.sunstone_prepend || ''
const regexpSplitLine = /\r|\n/
@ -39,7 +50,7 @@ const saveAsTemplate = (
params = {},
userData = {}
) => {
let rtn = httpBadRequest
let rtn = httpResponse(badRequest, '', '')
const { id, name, persistent } = params
if (id && name) {
let message = ''
@ -66,7 +77,216 @@ const saveAsTemplate = (
next()
}
/**
* Generates a session to connect a VM by id through Guacamole.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {string} params.id - VM id
* @param {'vnc'|'ssh'|'rdp'} params.type - Type connection
* @param {object} userData - user of http request
* @param {Function} xmlrpc - XML-RPC function
*/
const generateGuacamoleSession = (
res = {},
next = defaultEmptyFunction,
params = {},
userData = {},
xmlrpc = defaultEmptyFunction
) => {
const { id: userAuthId } = userData
const { id: vmId, type } = params
const ensuredType = `${type}`.toLowerCase()
if (!['vnc', 'ssh', 'rdp'].includes(ensuredType)) {
const messageError = "Type connection isn't supported by Guacamole"
res.locals.httpCode = httpResponse(badRequest, messageError)
next()
}
const serverAdmin = getSunstoneAuth() ?? {}
const { token: authToken } = createTokenServerAdmin(serverAdmin) ?? {}
if (!authToken) {
res.locals.httpCode = httpResponse(badRequest, '')
next()
}
const { username } = serverAdmin
const oneClient = xmlrpc(`${username}:${username}`, authToken)
// get authenticated user
oneClient({
action: USER_INFO,
parameters: [parseInt(userAuthId, 10), true],
callback: (userInfoErr, { USER } = {}) => {
if (userInfoErr || !USER) {
res.locals.httpCode = httpResponse(badRequest, userInfoErr)
next()
}
// get VM information by id
oneClient({
action: VM_INFO,
parameters: [parseInt(vmId, 10), true],
callback: (vmInfoErr, { VM } = {}) => {
if (vmInfoErr || !VM) {
res.locals.httpCode = httpResponse(unauthorized, vmInfoErr)
next()
}
const settings = {
vnc: () => getVncSettings(VM),
ssh: () => getSshSettings(VM, USER),
rdp: () => getRdpSettings(VM),
}[ensuredType]?.() ?? { error: '' }
if (settings.error) {
res.locals.httpCode = httpResponse(badRequest, settings.error)
next()
}
// const minutesToAdd = 1
// const currentDate = new Date()
// const expiration = currentDate.getTime() + minutesToAdd * 60000
const connection = {
// expiration,
connection: {
type: ensuredType,
settings: {
security: 'any',
'ignore-cert': 'true',
'enable-drive': 'true',
'create-drive-path': 'true',
...settings,
},
},
}
const wsToken = JSON.stringify(encryptConnection(connection))
const encodedWsToken = Buffer.from(wsToken).toString('base64')
res.locals.httpCode = httpResponse(ok, encodedWsToken)
next()
},
})
},
})
}
const getVncSettings = (vmInfo) => {
const config = {}
if (`${vmInfo.USER_TEMPLATE?.HYPERVISOR}`.toLowerCase() === 'vcenter') {
const esxHost = vmInfo?.MONITORING?.VCENTER_ESX_HOST
if (!esxHost) {
return {
error: `Could not determine the vCenter ESX host where
the VM is running. Wait till the VCENTER_ESX_HOST attribute is
retrieved once the host has been monitored`,
}
}
config.hostname = esxHost
}
if (!config.hostname) {
const lastHistory = [vmInfo.HISTORY_RECORDS?.HISTORY ?? []].flat().at(-1)
config.hostname = lastHistory?.HOSTNAME ?? 'localhost'
}
config.port = vmInfo.TEMPLATE?.GRAPHICS?.PORT ?? '5900'
config.password = vmInfo.TEMPLATE?.GRAPHICS?.PASSWD ?? null
return config
}
const getSshSettings = (vmInfo, authUser) => {
const config = {}
const nics = [
vmInfo.TEMPLATE?.NIC ?? [],
vmInfo.TEMPLATE?.NIC_ALIAS ?? [],
].flat()
const nicWithExternalPortRange = nics.find((nic) => !nic.EXTERNAL_PORT_RANGE)
const { EXTERNAL_PORT_RANGE } = nicWithExternalPortRange ?? {}
if (EXTERNAL_PORT_RANGE) {
const lastHistory = [vmInfo.HISTORY_RECORDS?.HISTORY ?? []].flat().at(-1)
const lastHostname = lastHistory?.HOSTNAME
if (lastHostname) {
config.hostname = lastHostname
config.port = parseInt(EXTERNAL_PORT_RANGE.split(':')[0], 10) + 21
}
}
if (!config.hostname) {
const nicWithSsh = nics.find(({ SSH }) => `${SSH}`.toLowerCase() === 'yes')
config.hostname = nicWithSsh?.EXTERNAL_IP ?? nicWithSsh?.IP
}
if (!config.hostname) {
return { error: 'Wrong configuration. Cannot find a NIC with SSH' }
}
config.port ??= vmInfo.TEMPLATE?.CONTEXT?.SSH_PORT ?? '22'
if (vmInfo.TEMPLATE?.CONTEXT?.SSH_PUBLIC_KEY) {
config['private-key'] = authUser?.TEMPLATE?.SSH_PRIVATE_KEY
config.passphrase = authUser?.TEMPLATE?.SSH_PASSPHRASE
} else {
config.username = vmInfo.TEMPLATE?.CONTEXT?.USERNAME
config.password = vmInfo.TEMPLATE?.CONTEXT?.PASSWORD
}
return config
}
const getRdpSettings = (vmInfo) => {
const config = {}
const nics = [
vmInfo.TEMPLATE?.NIC ?? [],
vmInfo.TEMPLATE?.NIC_ALIAS ?? [],
].flat()
const nicWithRdp = nics.find(({ RDP }) => `${RDP}`.toLowerCase() === 'yes')
config.hostname = nicWithRdp?.EXTERNAL_IP ?? nicWithRdp?.IP
if (!config.hostname) {
return { error: 'Wrong configuration. Cannot find a NIC with RDP' }
}
config.port = vmInfo.TEMPLATE?.CONTEXT?.RDP_PORT ?? '3389'
config.username = vmInfo.TEMPLATE?.CONTEXT?.USERNAME
config.password = vmInfo.TEMPLATE?.CONTEXT?.PASSWORD
config['resize-method'] = 'display-update'
if (config.username && config.password) config.security = 'nla'
return config
}
const encryptConnection = (data) => {
const iv = randomBytes(16)
const key = global.paths.FIREEDGE_KEY
const cipher = createCipheriv(defaultTypeCrypto, key, iv)
const ensuredData = typeof data === 'string' ? data : JSON.stringify(data)
let value = cipher.update(ensuredData, 'utf-8', 'base64')
value += cipher.final('base64')
return { iv: iv.toString('base64'), value }
}
const functionRoutes = {
saveAsTemplate,
generateGuacamoleSession,
}
module.exports = functionRoutes

@ -15,13 +15,20 @@
* ------------------------------------------------------------------------- */
const { Actions, Commands } = require('server/routes/api/vm/routes')
const { saveAsTemplate } = require('server/routes/api/vm/functions')
const {
saveAsTemplate,
generateGuacamoleSession,
} = require('server/routes/api/vm/functions')
const { VM_SAVEASTEMPLATE } = Actions
const { VM_SAVEASTEMPLATE, GUACAMOLE } = Actions
module.exports = [
{
...Commands[VM_SAVEASTEMPLATE],
action: saveAsTemplate,
},
{
...Commands[GUACAMOLE],
action: generateGuacamoleSession,
},
]

@ -17,15 +17,18 @@
const {
httpMethod,
from: fromData,
} = require('server/utils/constants/defaults')
} = require('../../../utils/constants/defaults')
const basepath = '/vm'
const { POST } = httpMethod
const { POST, GET } = httpMethod
const { resource, postBody } = fromData
const VM_SAVEASTEMPLATE = 'vm.saveastemplate'
const GUACAMOLE = 'vm.guacamole'
const Actions = {
VM_SAVEASTEMPLATE,
GUACAMOLE,
}
module.exports = {
@ -47,5 +50,18 @@ module.exports = {
},
},
},
[GUACAMOLE]: {
path: `${basepath}/:id/guacamole/:type`,
httpMethod: GET,
auth: true,
params: {
id: {
from: resource,
},
type: {
from: resource,
},
},
},
},
}