feat(ui):add rdp&vnc (#15)

This commit is contained in:
dagongren
2024-03-07 09:47:15 +08:00
committed by GitHub
parent ebe705c5c6
commit 5636b2cf95
16 changed files with 636 additions and 46 deletions

View File

@@ -31,6 +31,7 @@
"enquire.js": "^2.1.6",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"guacamole-common-js": "1.4.0-a",
"is-buffer": "^2.0.5",
"jquery": "^3.6.0",
"js-cookie": "^2.2.0",

View File

@@ -38,7 +38,13 @@ export default {
return enUS
},
isOpsFullScreen() {
return ['cmdb_screen', 'oneterm_terminal', 'oneterm_replay'].includes(this.$route.name)
return [
'cmdb_screen',
'oneterm_terminal',
'oneterm_replay',
'oneterm_guacamole',
'oneterm_replay_guacamole',
].includes(this.$route.name)
},
isOpsOnlyTopMenu() {
return ['fullscreen_index', 'setting_person', 'notice_center'].includes(this.$route.name)

View File

@@ -7,9 +7,13 @@ export function closeConnect(session_id) {
})
}
export function postConnectIsRight(asset_id, account_id, protocol) {
export function postConnectIsRight(asset_id, account_id, protocol, query = null) {
let url = `/oneterm/v1/connect/${asset_id}/${account_id}/${protocol}`
if (query) {
url = `${url}?${query}`
}
return axios({
url: `/oneterm/v1/connect/${asset_id}/${account_id}/${protocol}`,
url,
method: 'post',
})
}

View File

@@ -140,6 +140,14 @@ const oneterm_en = {
param: 'Param',
before: 'Before',
after: 'After',
},
guacamole: {
play: 'click to play',
idle: 'Initializing...',
connecting: 'Connecting...',
waiting: 'Waiting...',
connected: 'Connected',
disconnect: 'Disconnect'
}
}
export default oneterm_en

View File

@@ -140,6 +140,14 @@ const oneterm_zh = {
param: '属性',
before: '操作前',
after: '操作后',
},
guacamole: {
play: '点击播放',
idle: '正在初始化中...',
connecting: '正在努力连接中...',
waiting: '正在等待服务器响应...',
connected: '连接成功',
disconnect: '连接已关闭'
}
}
export default oneterm_zh

View File

@@ -97,12 +97,26 @@ const genOnetermRoutes = () => {
component: () => import('../views/terminal'),
meta: { title: '终端', keepAlive: false }
},
{
path: '/oneterm/guacamole/:asset_id/:account_id/:protocol',
name: 'oneterm_guacamole',
hidden: true,
component: () => import('../views/terminal/guacamoleClient.vue'),
meta: { title: '终端', keepAlive: false }
},
{
path: '/oneterm/replay/:session_id',
name: 'oneterm_replay',
hidden: true,
component: () => import('../views/replay'),
meta: { title: '回放', icon: 'ops-itsm-servicedesk', selectedIcon: 'ops-itsm-servicedesk-selected', keepAlive: false }
meta: { title: '回放', keepAlive: false }
},
{
path: '/oneterm/replay/guacamole/:session_id',
name: 'oneterm_replay_guacamole',
hidden: true,
component: () => import('../views/replay/guacamoleReplay.vue'),
meta: { title: '回放', keepAlive: false }
},
]
}

View File

@@ -17,3 +17,32 @@ export const setLocalStorage = (name, param) => {
}
localStorage.setItem(name, JSON.stringify(storageData))
}
class Strings {
hasText = function (text) {
return !(text === undefined || text === null || text.length === 0)
}
zeroPad = function zeroPad(num, minLength) {
let str = num.toString()
while (str.length < minLength) { str = '0' + str }
return str
};
}
export const strings = new Strings()
class Times {
formatTime = function formatTime(millis) {
const totalSeconds = Math.floor(millis / 1000)
// Split into seconds and minutes
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60)
// Format seconds and minutes as MM:SS
return strings.zeroPad(minutes, 2) + ':' + strings.zeroPad(seconds, 2)
};
}
export const times = new Times()

View File

@@ -118,15 +118,13 @@ export default {
return this.$t(`oneterm.assetList.editAsset`)
},
},
mounted() {
getNodeList().then((res) => {
this.nodeList = res?.data?.list || []
})
},
methods: {
setAsset(asset, type) {
this.visible = true
this.type = type
getNodeList().then((res) => {
this.nodeList = res?.data?.list || []
})
this.$nextTick(() => {
const {
id = null,

View File

@@ -60,7 +60,7 @@
prop="type_id"
:style="{ display: 'flex', alignItems: 'center' }"
>
<CMDBTypeSelect v-model="syncForm.type_id" selectType="ci_type" @select="changeTypeId" />
<CMDBTypeSelect v-model="syncForm.type_id" selectType="ci_type" @change="changeTypeId" />
</a-form-model-item>
<a-form-model-item :label="$t('oneterm.fieldMap')">
<div v-for="item in fieldMap" :key="item.id">
@@ -105,7 +105,7 @@
</treeselect>
</div>
</div>
<span v-if="item.error" style="color:red">{{ `${$t(`placeholder2`)}` }}</span>
<span v-if="item.error" style="color: red">{{ `${$t(`placeholder2`)}` }}</span>
</div>
</a-form-model-item>
<a-form-model-item :label="$t('oneterm.filter')" class="cmdb-value-filter">
@@ -215,7 +215,7 @@ export default {
setNode(node, type) {
this.visible = true
this.type = type
this.$nextTick(() => {
this.$nextTick(async () => {
const params = {}
if (node?.id) {
params.no_self_child = node.id
@@ -247,26 +247,26 @@ export default {
enable,
frequency,
}
this.changeTypeId({ id: type_id })
this.fieldMap =
JSON.stringify(mapping) === '{}'
? [
{
field_name: undefined,
attribute: { value: 'name', label: this.$t('oneterm.name') },
},
{
field_name: undefined,
attribute: { value: 'ip', label: 'IP' },
},
]
: Object.keys(mapping).map((key) => {
return {
field_name: mapping[key],
attribute: { value: key, label: this.fieldMapObj[key] },
}
})
this.$nextTick(() => {
this.fieldMap =
JSON.stringify(mapping) === '{}'
? [
{
field_name: undefined,
attribute: { value: 'name', label: this.$t('oneterm.name') },
},
{
field_name: undefined,
attribute: { value: 'ip', label: 'IP' },
},
]
: Object.keys(mapping).map((key) => {
return {
field_name: mapping[key],
attribute: { value: key, label: this.fieldMapObj[key] },
}
})
})
this.filterExp = filters
this.$nextTick(() => {
this.$refs.filterComp.visibleChange(true, false)
@@ -276,12 +276,24 @@ export default {
this.$refs.accessAuth.setValues(access_auth)
})
},
async changeTypeId({ id }) {
async changeTypeId(id) {
this.fieldMap = [
{
field_name: undefined,
attribute: { value: 'name', label: this.$t('oneterm.name') },
},
{
field_name: undefined,
attribute: { value: 'ip', label: 'IP' },
},
]
if (id) {
await getCITypeAttributesById(id).then((res) => {
const { attributes } = res
this.attributes = attributes
})
} else {
this.attributes = []
}
},
setExpFromFilter(filterExp) {

View File

@@ -94,11 +94,15 @@ export default {
if (valid) {
this.handleCancel()
const { account_id, protocol } = this.loginForm
postConnectIsRight(this.asset_id, account_id, protocol).then((res) => {
if (res?.data?.session_id) {
window.open(`/oneterm/terminal?session_id=${res?.data?.session_id}`, '_blank')
}
})
if (protocol.includes('rdp') || protocol.includes('vnc')) {
window.open(`/oneterm/guacamole/${this.asset_id}/${account_id}/${protocol}`, '_blank')
} else {
postConnectIsRight(this.asset_id, account_id, protocol).then((res) => {
if (res?.data?.session_id) {
window.open(`/oneterm/terminal?session_id=${res?.data?.session_id}`, '_blank')
}
})
}
}
})
},

View File

@@ -3,12 +3,22 @@
<a-form-model-item :label="$t('oneterm.protocol')">
<div class="protocol-box" v-for="(pro, index) in protocols" :key="pro.id">
<a-input-group compact>
<a-select v-model="pro.value" style="width: 100px">
<a-select v-model="pro.value" style="width: 100px" @change="(value) => changeProValue(value, index)">
<a-select-option value="ssh">
ssh
</a-select-option>
<a-select-option value="rdp">
rdp
</a-select-option>
<a-select-option value="vnc">
vnc
</a-select-option>
</a-select>
<a-input :placeholder="$t('oneterm.assetList.protocolPlaceholder')" v-model="pro.label" style="width: calc(100% - 100px)" />
<a-input
:placeholder="$t('oneterm.assetList.protocolPlaceholder')"
v-model="pro.label"
style="width: calc(100% - 100px)"
/>
</a-input-group>
<a-space>
<a @click="addPro"><a-icon type="plus-circle"/></a>
@@ -64,6 +74,7 @@
</template>
<script>
import _ from 'lodash'
import { v4 as uuidv4 } from 'uuid'
import { getGatewayList } from '../../../api/gateway'
export default {
@@ -105,6 +116,19 @@ export default {
}))
: [{ id: uuidv4(), value: 'ssh', label: '22' }]
},
changeProValue(value, index) {
const _pro = _.cloneDeep(this.protocols[index])
if (value === 'rdp') {
_pro.label = '3389'
}
if (value === 'vnc') {
_pro.label = '5900'
}
if (value === 'ssh') {
_pro.label = '22'
}
this.$set(this.protocols, index, _pro)
},
},
}
</script>

View File

@@ -0,0 +1,241 @@
<template>
<div class="oneterm-guacamole-replay-wrapper">
<div class="oneterm-guacamole-replay" id="display"></div>
<span
v-if="waiting"
@click="
() => {
waiting = false
handlePlayPause()
}
"
>{{ $t('oneterm.guacamole.play') }}</span
>
<a-row
v-else
class="oneterm-guacamole-replay-progress"
type="flex"
justify="space-around"
align="middle"
:gutter="[5, 5]"
>
<a-col><a-icon @click="handlePlayPause" :type="playBtnIcon"/></a-col>
<a-col flex="1">
<a-slider
:tip-formatter="formatter"
:max="max"
:value="percent"
@change="handleProgressChange"
/></a-col>
<a-col>
<a-select
size="small"
:style="{ width: '90px' }"
:value="speed"
@change="
(value) => {
speed = value
if (value === 1) {
stopSpeedUp()
} else {
startSpeedUp()
}
}
"
>
<a-select-option :value="1">
1倍速
</a-select-option>
<a-select-option :value="1.25">
1.25倍速
</a-select-option>
<a-select-option :value="1.5">
1.5倍速
</a-select-option>
<a-select-option :value="2">
2倍速
</a-select-option>
</a-select>
</a-col>
<a-col>{{ position }}/ {{ duration }}</a-col>
</a-row>
</div>
</template>
<script>
import Guacamole from 'guacamole-common-js'
import { times } from '../../utils'
export default {
name: 'GuacamoleReplay',
data() {
return {
recording: null,
percent: 0,
speed: 1,
max: 0,
position: '00:00',
duration: '00:00',
waiting: true,
timer: null,
playBtnIcon: 'play-circle',
}
},
mounted() {
window.addEventListener('resize', this.onWindowResize)
this.init()
},
beforeDestroy() {
window.removeEventListener('resize', this.onWindowResize)
if (this.recording) {
this.recording.disconnect()
}
},
methods: {
init() {
const { session_id } = this.$route.params
const RECORDING_URL = `/api/oneterm/v1/session/replay/${session_id}`
const tunnel = new Guacamole.StaticHTTPTunnel(RECORDING_URL)
tunnel.onstatechange = this.onTunnelStateChange
const recording = new Guacamole.SessionRecording(tunnel)
const recordingDisplay = recording.getDisplay()
const display = document.getElementById('display')
display.appendChild(recordingDisplay.getElement())
recording.connect()
// If playing, the play/pause button should read "Pause"
recording.onplay = () => {
this.playBtnIcon = 'pause-circle'
}
// If paused, the play/pause button should read "Play"
recording.onpause = () => {
this.playBtnIcon = 'play-circle'
}
recording.onseek = (millis) => {
this.percent = millis
this.position = times.formatTime(millis)
}
recording.onprogress = (millis) => {
this.max = millis
this.duration = times.formatTime(millis)
}
this.recording = recording
},
onTunnelStateChange(state) {
switch (state) {
case Guacamole.Tunnel.State.OPEN:
this.handlePlayPause()
break
case Guacamole.Tunnel.State.CLOSED:
break
default:
break
}
},
handlePlayPause() {
if (this.percent === this.max) {
// 重播
this.percent = 0
this.recording.seek(0, () => {
this.recording.play()
this.startSpeedUp()
})
}
if (!this.recording.isPlaying()) {
this.recording.play()
this.startSpeedUp()
} else {
this.recording.pause()
this.stopSpeedUp()
this.$message.info('暂停')
}
},
startSpeedUp() {
this.stopSpeedUp()
if (this.speed === 1) {
return
}
if (!this.recording.isPlaying()) {
return
}
const add_time = 100
const delay = 1000 / (1000 / add_time) / (this.speed - 1)
const max = this.recording.getDuration()
const current = this.recording.getPosition()
if (current >= max) {
return
}
this.recording.seek(current + add_time, () => {
this.timer = setTimeout(this.startSpeedUp, delay)
})
},
stopSpeedUp() {
if (this.timer) {
clearTimeout(this.timer)
}
},
handleProgressChange(value) {
this.recording.seek(value, () => {
console.log('complete')
})
},
formatter(value) {
return `${times.formatTime(value)}`
},
onWindowResize() {
const { recording } = this
const width = recording.getDisplay().getWidth()
const height = recording.getDisplay().getHeight()
const winWidth = window.innerWidth
const winHeight = window.innerHeight - 40
const scaleW = winWidth / width
const scaleH = winHeight / height
const scale = Math.min(scaleW, scaleH)
if (!scale) {
return
}
recording.getDisplay().scale(scale)
},
},
}
</script>
<style lang="less" scoped>
.oneterm-guacamole-replay-wrapper {
background: black;
> span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
cursor: pointer;
font-size: 20px;
font-weight: 700;
}
.oneterm-guacamole-replay-progress {
width: 100%;
background: black;
color: white;
font-weight: bold;
position: absolute;
bottom: 0;
}
}
.oneterm-guacamole-replay-wrapper,
.oneterm-guacamole-replay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
</style>

View File

@@ -230,10 +230,22 @@ export default {
return time
},
openReplay(row) {
window.open(`/oneterm/replay/${row.session_id}`, '_blank')
if (row.protocol.includes('rdp') || row.protocol.includes('vnc')) {
window.open(`/oneterm/replay/guacamole/${row.session_id}`, '_blank')
} else {
window.open(`/oneterm/replay/${row.session_id}`, '_blank')
}
},
openMonitor(row) {
window.open(`/oneterm/terminal?session_id=${row.session_id}&&is_monitor=true`, '_blank')
if (row.protocol.includes('rdp') || row.protocol.includes('vnc')) {
const { asset_id, account_id, protocol } = row
window.open(
`/oneterm/guacamole/${asset_id}/${account_id}/${protocol}?session_id=${row.session_id}&&is_monitor=true`,
'_blank'
)
} else {
window.open(`/oneterm/terminal?session_id=${row.session_id}&&is_monitor=true`, '_blank')
}
},
disconnect(row) {
this.loading = true

View File

@@ -0,0 +1,228 @@
<template>
<div class="oneterm-guacamole" id="display"></div>
</template>
<script>
import _ from 'lodash'
import Guacamole from 'guacamole-common-js'
import { postConnectIsRight } from '../../api/connect'
const STATE_IDLE = 0
const STATE_CONNECTING = 1
const STATE_WAITING = 2
const STATE_CONNECTED = 3
const STATE_DISCONNECTING = 4
const STATE_DISCONNECTED = 5
export default {
name: 'GuacamoleClient',
data() {
return {
client: null,
session_id: null,
is_monitor: this.$route.query.is_monitor,
}
},
mounted() {
window.addEventListener('resize', this.onWindowResize)
const guacamoleClient = document.getElementById('display')
const { asset_id, account_id, protocol } = this.$route.params
const { session_id, is_monitor } = this.$route.query
if (session_id) {
this.session_id = session_id
this.init()
} else {
postConnectIsRight(
asset_id,
account_id,
protocol,
`w=${guacamoleClient.clientWidth}&h=${guacamoleClient.clientHeight}&dpi=${96}`
).then((res) => {
this.session_id = res?.data?.session_id
if (this.session_id) {
this.init()
}
})
}
},
beforeDestroy() {
window.removeEventListener('resize', this.onWindowResize)
if (this.client) {
this.client.disconnect()
}
},
methods: {
init() {
const { session_id, is_monitor } = this
const tunnel = new Guacamole.WebSocketTunnel(
is_monitor
? `ws://${document.location.host}/api/oneterm/v1/connect/monitor/${session_id}`
: `ws://${document.location.host}/api/oneterm/v1/connect/${session_id}`
)
const client = new Guacamole.Client(tunnel)
// 处理从虚拟机收到的剪贴板内容
// client.onclipboard = handleClipboardReceived
// 处理客户端的状态变化事件
client.onstatechange = (state) => {
this.onClientStateChange(state)
}
client.onerror = this.onError
client.ondisconnect = this.onDisconnect
tunnel.onerror = this.onError
// Get display div from document
const displayEle = document.getElementById('display')
// Add client to display div
const element = client.getDisplay().getElement()
displayEle.appendChild(element)
client.connect(`w=${displayEle.clientWidth}&h=${displayEle.clientHeight}&dpi=96`)
const display = client.getDisplay()
display.onresize = function(width, height) {
display.scale(Math.min(window.innerHeight / display.getHeight(), window.innerWidth / display.getHeight()))
}
const sink = new Guacamole.InputSink()
displayEle.appendChild(sink.getElement())
sink.focus()
const keyboard = new Guacamole.Keyboard(sink.getElement())
keyboard.onkeydown = (keysym) => {
client.sendKeyEvent(1, keysym)
if (keysym === 65288) {
return false
}
}
keyboard.onkeyup = (keysym) => {
client.sendKeyEvent(0, keysym)
}
const sinkFocus = _.debounce(() => {
sink.focus()
})
const mouse = new Guacamole.Mouse(element)
mouse.onmousedown = mouse.onmouseup = function(mouseState) {
sinkFocus()
if (!is_monitor) {
client.sendMouseState(mouseState)
}
}
mouse.onmousemove = function(mouseState) {
sinkFocus()
client.getDisplay().showCursor(false)
mouseState.x = mouseState.x / display.getScale()
mouseState.y = mouseState.y / display.getScale()
if (!is_monitor) {
client.sendMouseState(mouseState)
}
}
const touch = new Guacamole.Mouse.Touchpad(element) // or Guacamole.Touchscreen
touch.onmousedown = touch.onmousemove = touch.onmouseup = function(state) {
if (!is_monitor) {
client.sendMouseState(state)
}
}
this.client = client
},
onClientStateChange(state) {
console.log(state)
const key = 'message'
switch (state) {
case STATE_IDLE:
this.$message.destroy(key)
this.$message.loading({ content: this.$t('oneterm.guacamole.idle'), duration: 0, key: key })
break
case STATE_CONNECTING:
this.$message.destroy(key)
this.$message.loading({ content: this.$t('oneterm.guacamole.connecting'), duration: 0, key: key })
break
case STATE_WAITING:
this.$message.destroy(key)
this.$message.loading({ content: this.$t('oneterm.guacamole.waiting'), duration: 0, key: key })
break
case STATE_CONNECTED:
this.$message.destroy(key)
this.$message.success({ content: this.$t('oneterm.guacamole.connected'), duration: 3, key: key })
// 向后台发送请求,更新会话的状态
// sessionApi.connect(sessionId)
break
case STATE_DISCONNECTING:
break
case STATE_DISCONNECTED:
if (this.client) {
this.client.disconnect()
}
const guacamoleClient = document.getElementById('display')
guacamoleClient.innerHTML = ''
break
default:
break
}
},
onWindowResize() {
const { client } = this
const width = client.getDisplay().getWidth()
const height = client.getDisplay().getHeight()
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const scaleW = winWidth / width
const scaleH = winHeight / height
const scale = Math.min(scaleW, scaleH)
if (!scale) {
return
}
client.getDisplay().scale(scale)
},
onError(status) {
if (status.code > 1000) {
this.$message.info({
content: decodeURIComponent(
atob(status.message)
.split('')
.map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
})
.join('')
),
})
} else if (status.code < 1000 && status.message) {
this.$message.info(status.message)
}
},
onDisconnect() {
console.log(2222)
},
},
}
</script>
<style lang="less" scoped>
.oneterm-guacamole {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: black;
}
</style>
<style lang="less">
.oneterm-guacamole > div {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
</style>

View File

@@ -63,7 +63,8 @@ export default {
this.websocket = new WebSocket(
is_monitor
? `${protocol}://${document.location.host}/api/oneterm/v1/connect/monitor/${session_id}?w=${this.term.cols}&h=${this.term.rows}`
: `${protocol}://${document.location.host}/api/oneterm/v1/connect/${session_id}?w=${this.term.cols}&h=${this.term.rows}`
: `${protocol}://${document.location.host}/api/oneterm/v1/connect/${session_id}?w=${this.term.cols}&h=${this.term.rows}`,
['Sec-WebSocket-Protocol']
)
this.websocket.onopen = this.websocketOpen()
this.websocket.onmessage = this.getMessage

View File

@@ -19,13 +19,13 @@
<vxe-column :title="$t(`oneterm.sessionTable.clientIp`)" field="client_ip"> </vxe-column>
<vxe-column :title="$t(`oneterm.protocol`)" field="protocol"> </vxe-column>
<vxe-column :title="$t(`operation`)" width="80" align="center">
<template #default="{ row }">
<template #default="{row}">
<a-space>
<a-tooltip :title="$t(`login`)">
<a @click="openTerminal(row)"><ops-icon type="oneterm-login" /></a>
<a @click="openTerminal(row)"><ops-icon type="oneterm-login"/></a>
</a-tooltip>
<a-tooltip :title="$t(`oneterm.switchAccount`)">
<a @click="openLogin(row)"><ops-icon type="oneterm-switch" /></a>
<a @click="openLogin(row)"><ops-icon type="oneterm-switch"/></a>
</a-tooltip>
</a-space>
</template>