前言

公司的AI Pass平台需要能在网页上查看一些跑在k8s容器中的服务相关的信息或进行一些操作。

思路

在前端页面上打开一个终端,前后端建立websocket通信,前端每输入一个字符,后端都与k8s进行交互然后把返回信息封装了发送到前端并展示在页面终端上。

实现代码

<script setup name="Terminal">
import 'xterm/css/xterm.css'
import 'xterm/lib/xterm.js'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebsocketManager } from '@/util/websocket'
import { useDebounceFn } from '@vueuse/core'
import { terminalTypeEnum, ThemeEnum } from '@/enums/applicationEnum'

const { proxy } = getCurrentInstance()

const props = defineProps({
  //连接ur1
  socketUrl: {
    type: String,
    require: true
  },
  //终端类型
  type: {
    type: Number,
    default: terminalTypeEnum.TERMINAL,
    validator: value => Object.values(terminalTypeEnum).includes(value)
  },
  // 展示用户需知信息
  showInfo: {
    type: Boolean,
    default: false
  },
  //主题
  theme: {
    type: String,
    default: ThemeEnum.DARK,
    validator: value => Object.values(ThemeEnum).includes(value)
  }
})


const terminalSocket = ref(null)

const message = (command, type = 'stdin') => {
  return JSON.stringify({
    type,
    command
  })
}

const resizeMessage = (width, height) => {
  return JSON.stringify({
    type: 'resize',
    width,
    height
  })
}

const getTheme = () => {
  const themeOption = {
    [ThemeEnum.DARK]: {
      foreground: '#ddd'
    },
    [ThemeEnum.LIGHT]: {
      background: 'white',//设置背景色为白色
      // 设置前景色为黑色
      foreground: 'black'
    }
  }
  return themeOption[props.theme]
}

class TerminalWebsocket extends WebsocketManager {
  constructor(url) {
    super(url)
    this.term = null
    this.fitAddon = null
    this.resizeTerm = useDebounceFn(() => {
      //自适应大小(使终端的尺寸和几何尺寸适合于终端容器的尺寸)
      this.fitAddon.fit()
      const { rows, cols } = this.term
      this.socket.send(resizeMessage(cols, rows))
      this.term.scrollToBottom()
    }, 300)
  }

  init() {
    super.init()
    //设置心跳保持websocket活跃,间隔15s,nginx超时时间1min
    this.setHeartbeat(true, 15 * 1000, message(' '))
  }

  //接受处理后端推送的信息
  handleSocketMessage() {
    this.socket.onmessage = event => {
      const { command, type } = JSON.parse(event.data)
      // console. log ('command', command)

      if (props.type === 1) {
        // 日志
        if (!this.term)
          this.initTerm()

        this.term.writeln(command)
      } else if (props.type === 0) {
        //终端
        //连接成功后等后端推送了初始化信息先初始化,初始化后再处理后端发过来的其他信息
        if (type === 'init') {
          // 初始化
          this.initTerm()
        } else {
          // 处理返回信息
          if (command.length === 3 && encodeURIComponent(command).slice(1) === '$20%0D') {
            //暂时好像没用处
            this.term.write(command.slice(0, 1))
          } else {
            this.term.write(command)
          }
        }
      }
    }
  }

  // websocket关闭
  handleSocketClose() {
    super.handleSocketClose()

  }

  // 手动关闭websocket
  closeWebSocket() {
    super.closeWebSocket()
    window.removeEventListener('resize', this.resizeTerm)
  }

  initTerm() {
    // let element = document.querySelector ('#xterm' )
    let element = proxy.$refs.xterm
    //设置了cols或者fitAddon.fit();当一行字符超过80个过会替换现在的内容,否则换行
    this.term = new Terminal({
      cursorBlink: true, //
      cursorStyle: 'bar', //5t 'block' | 'underline' | 'bar'
      scrollback: 3000,//当行的滚动超过初始值时保留的行视窗,越大回滚能看的内容越多,
      bufferSize: 999999,  //设置终端缓冲区大小为9999999
      convertEol: true,//光标设为每一行开头
      fontSize: 14,
      lineHeight: 1.5,
      minimumContrastRatio: 21, //所有字体显示白色高亮
      theme: getTheme(),
      cursorWidth: 3
    })

    // 自适应插件
    this.fitAddon = new FitAddon()
    this.term.loadAddon(this.fitAddon)
    this.term.open(element)
    this.fitAddon.fit()

    // !!! 一开始自适应不成功可能是因为我终端组件加载了,但是当前页面页签在jupyter,自适应就没成功,所以现在给终端加了缓存,第一次切进进来的时候才加载
    //此处datas一定不要改 !!!
    this.term.onData(datas => {
      // console.log ('data', datas, message (datas) )
      if (props.type === 0) {
        this.socket.send(message(datas))
      }
    })
    window.addEventListener('resize', this.resizeTerm)

    if (props.showInfo) {
      this.writeExtraInfo()

    }

    //向后端发送一些初始化的信息
    const { rows, cols } = this.term
    this.socket.send(resizeMessage(cols, rows))
    this.term.focus()
  }

  writeExtraInfo() {
    this.term.writeln('\n用户需知:请提前阅读本文档\n......')
  }
}

const key = ref(0)
watch(
  () => props.socketUrl,
  val => {
    if (val) {
      if (terminalSocket.value) {
        terminalSocket.value.closeWebSocket()
        //由于xtermjs有缓冲区,上一次websocket连接后端推过来太多的数据,哪怕关了websocket,还有很多未写入终端的数据仍在写入
        key.value += 1
      }
      terminalSocket.value = new TerminalWebsocket(props.socketUrl)
      terminalSocket.value.init()
    }
  }, { immediate: true }
)

onBeforeUnmount(() => {
  terminalSocket.value.closeWebSocket()
})

defineExpose({
  resize
})
</script>

<template>
<div class="terminal-container">
<div :key="key" ref="xterm" class="xterm" />
</div>
</template>
<style lang="scss" scoped>
.terminal-container {
  background-color: #000;
  width: 100%;
  height: 100%;
  padding: 0 20px;

  .xterm {
    width: 100%;
    height: 100%;
  }
}
</style>