feat: 本地连接重新实现,移除 gotty

This commit is contained in:
ssongliu 2022-08-23 15:21:08 +08:00 committed by ssongliu
parent 0f1ff3300e
commit 7089775109
17 changed files with 412 additions and 112 deletions

View File

@ -79,6 +79,48 @@ func (b *BaseApi) WsSsh(c *gin.Context) {
} }
} }
func (b *BaseApi) LocalWsSsh(c *gin.Context) {
cols, err := strconv.Atoi(c.DefaultQuery("cols", "80"))
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
rows, err := strconv.Atoi(c.DefaultQuery("rows", "40"))
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
global.LOG.Errorf("gin context http handler failed, err: %v", err)
return
}
defer wsConn.Close()
slave, err := terminal.NewCommand()
if wshandleError(wsConn, err) {
return
}
defer slave.Close()
tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave)
if wshandleError(wsConn, err) {
return
}
quitChan := make(chan bool, 3)
tty.Start(quitChan)
go slave.Wait(quitChan)
<-quitChan
global.LOG.Info("websocket finished")
if wshandleError(wsConn, err) {
return
}
}
func wshandleError(ws *websocket.Conn, err error) bool { func wshandleError(ws *websocket.Conn, err error) bool {
if err != nil { if err != nil {
global.LOG.Errorf("handler ws faled:, err: %v", err) global.LOG.Errorf("handler ws faled:, err: %v", err)

View File

@ -3,7 +3,7 @@ package dto
import "time" import "time"
type HostCreate struct { type HostCreate struct {
Name string `json:"name" validate:"required,name"` Name string `json:"name" validate:"required"`
Addr string `json:"addr" validate:"required,ip"` Addr string `json:"addr" validate:"required,ip"`
Port uint `json:"port" validate:"required,number,max=65535,min=1"` Port uint `json:"port" validate:"required,number,max=65535,min=1"`
User string `json:"user" validate:"required"` User string `json:"user" validate:"required"`
@ -27,7 +27,7 @@ type HostInfo struct {
} }
type HostUpdate struct { type HostUpdate struct {
Name string `json:"name" validate:"required,name"` Name string `json:"name" validate:"required"`
Addr string `json:"addr" validate:"required,ip"` Addr string `json:"addr" validate:"required,ip"`
Port uint `json:"port" validate:"required,number,max=65535,min=1"` Port uint `json:"port" validate:"required,number,max=65535,min=1"`
User string `json:"user" validate:"required"` User string `json:"user" validate:"required"`

View File

@ -15,6 +15,7 @@ require (
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/gwatts/gin-adapter v1.0.0 github.com/gwatts/gin-adapter v1.0.0
github.com/jinzhu/copier v0.3.5 github.com/jinzhu/copier v0.3.5
github.com/kr/pty v1.1.1
github.com/mojocn/base64Captcha v1.3.5 github.com/mojocn/base64Captcha v1.3.5
github.com/natefinch/lumberjack v2.0.0+incompatible github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/nicksnyder/go-i18n/v2 v2.1.2 github.com/nicksnyder/go-i18n/v2 v2.1.2

View File

@ -257,6 +257,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View File

@ -1,23 +0,0 @@
package binary
import (
"io"
"os"
"os/exec"
"github.com/1Panel-dev/1Panel/global"
)
func StartTTY() {
cmd := "gotty"
params := []string{"--permit-write", "bash"}
go func() {
c := exec.Command(cmd, params...)
c.Env = append(c.Env, os.Environ()...)
c.Stdout = io.Discard
c.Stderr = io.Discard
if err := c.Run(); err != nil {
global.LOG.Error(err)
}
}()
}

View File

@ -2,8 +2,10 @@ package middleware
import ( import (
"bytes" "bytes"
"encoding/json"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -16,11 +18,24 @@ import (
func OperationRecord() gin.HandlerFunc { func OperationRecord() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var body []byte var body []byte
if c.Request.Method == http.MethodGet || strings.Contains(c.Request.URL.Path, "search") { if strings.Contains(c.Request.URL.Path, "search") {
c.Next() c.Next()
return return
} }
if c.Request.Method == http.MethodGet {
query := c.Request.URL.RawQuery
query, _ = url.QueryUnescape(query)
split := strings.Split(query, "&")
m := make(map[string]string)
for _, v := range split {
kv := strings.Split(v, "=")
if len(kv) == 2 {
m[kv[0]] = kv[1]
}
}
body, _ = json.Marshal(&m)
} else {
var err error var err error
body, err = ioutil.ReadAll(c.Request.Body) body, err = ioutil.ReadAll(c.Request.Body)
if err != nil { if err != nil {
@ -28,6 +43,7 @@ func OperationRecord() gin.HandlerFunc {
} else { } else {
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
} }
}
pathInfo := loadLogInfo(c.Request.URL.Path) pathInfo := loadLogInfo(c.Request.URL.Path)
record := model.OperationLog{ record := model.OperationLog{

View File

@ -2,7 +2,6 @@ package router
import ( import (
v1 "github.com/1Panel-dev/1Panel/app/api/v1" v1 "github.com/1Panel-dev/1Panel/app/api/v1"
"github.com/1Panel-dev/1Panel/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -11,9 +10,9 @@ type TerminalRouter struct{}
func (s *UserRouter) InitTerminalRouter(Router *gin.RouterGroup) { func (s *UserRouter) InitTerminalRouter(Router *gin.RouterGroup) {
terminalRouter := Router.Group("terminals") terminalRouter := Router.Group("terminals")
withRecordRouter := terminalRouter.Use(middleware.OperationRecord())
baseApi := v1.ApiGroupApp.BaseApi baseApi := v1.ApiGroupApp.BaseApi
{ {
withRecordRouter.GET("", baseApi.WsSsh) terminalRouter.GET("", baseApi.WsSsh)
terminalRouter.GET("/local", baseApi.LocalWsSsh)
} }
} }

View File

@ -3,13 +3,13 @@ package server
import ( import (
"encoding/gob" "encoding/gob"
"fmt" "fmt"
"time"
"github.com/1Panel-dev/1Panel/init/cache" "github.com/1Panel-dev/1Panel/init/cache"
"github.com/1Panel-dev/1Panel/init/session" "github.com/1Panel-dev/1Panel/init/session"
"github.com/1Panel-dev/1Panel/init/session/psession" "github.com/1Panel-dev/1Panel/init/session/psession"
"time"
"github.com/1Panel-dev/1Panel/global" "github.com/1Panel-dev/1Panel/global"
"github.com/1Panel-dev/1Panel/init/binary"
"github.com/1Panel-dev/1Panel/init/db" "github.com/1Panel-dev/1Panel/init/db"
"github.com/1Panel-dev/1Panel/init/log" "github.com/1Panel-dev/1Panel/init/log"
"github.com/1Panel-dev/1Panel/init/migration" "github.com/1Panel-dev/1Panel/init/migration"
@ -30,7 +30,6 @@ func Start() {
gob.Register(psession.SessionUser{}) gob.Register(psession.SessionUser{})
cache.Init() cache.Init()
session.Init() session.Init()
binary.StartTTY()
gin.SetMode(global.CONF.System.Level) gin.SetMode(global.CONF.System.Level)
routers := router.Routers() routers := router.Routers()

View File

@ -0,0 +1,115 @@
package terminal
import (
"os"
"os/exec"
"syscall"
"time"
"unsafe"
"github.com/1Panel-dev/1Panel/global"
"github.com/kr/pty"
"github.com/pkg/errors"
)
const (
DefaultCloseSignal = syscall.SIGINT
DefaultCloseTimeout = 10 * time.Second
)
type LocalCommand struct {
command string
closeSignal syscall.Signal
closeTimeout time.Duration
cmd *exec.Cmd
pty *os.File
ptyClosed chan struct{}
}
func NewCommand() (*LocalCommand, error) {
command := "sh"
cmd := exec.Command(command)
cmd.Dir = "/"
pty, err := pty.Start(cmd)
if err != nil {
return nil, errors.Wrapf(err, "failed to start command `%s`", command)
}
ptyClosed := make(chan struct{})
lcmd := &LocalCommand{
command: command,
closeSignal: DefaultCloseSignal,
closeTimeout: DefaultCloseTimeout,
cmd: cmd,
pty: pty,
ptyClosed: ptyClosed,
}
return lcmd, nil
}
func (lcmd *LocalCommand) Read(p []byte) (n int, err error) {
return lcmd.pty.Read(p)
}
func (lcmd *LocalCommand) Write(p []byte) (n int, err error) {
return lcmd.pty.Write(p)
}
func (lcmd *LocalCommand) Close() error {
if lcmd.cmd != nil && lcmd.cmd.Process != nil {
_ = lcmd.cmd.Process.Signal(lcmd.closeSignal)
}
for {
select {
case <-lcmd.ptyClosed:
return nil
case <-lcmd.closeTimeoutC():
_ = lcmd.cmd.Process.Signal(syscall.SIGKILL)
}
}
}
func (lcmd *LocalCommand) ResizeTerminal(width int, height int) error {
window := struct {
row uint16
col uint16
x uint16
y uint16
}{
uint16(height),
uint16(width),
0,
0,
}
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
lcmd.pty.Fd(),
syscall.TIOCSWINSZ,
uintptr(unsafe.Pointer(&window)),
)
if errno != 0 {
return errno
} else {
return nil
}
}
func (lcmd *LocalCommand) Wait(quitChan chan bool) {
if err := lcmd.cmd.Wait(); err != nil {
global.LOG.Errorf("ssh session wait failed, err: %v", err)
setQuit(quitChan)
}
}
func (lcmd *LocalCommand) closeTimeoutC() <-chan time.Time {
if lcmd.closeTimeout >= 0 {
return time.After(lcmd.closeTimeout)
}
return make(chan time.Time)
}

View File

@ -0,0 +1,108 @@
package terminal
import (
"encoding/base64"
"encoding/json"
"sync"
"github.com/1Panel-dev/1Panel/global"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
)
type LocalWsSession struct {
slave *LocalCommand
wsConn *websocket.Conn
writeMutex sync.Mutex
}
func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalCommand) (*LocalWsSession, error) {
if err := slave.ResizeTerminal(cols, rows); err != nil {
global.LOG.Errorf("ssh pty change windows size failed, err: %v", err)
}
return &LocalWsSession{
slave: slave,
wsConn: wsConn,
}, nil
}
func (sws *LocalWsSession) Start(quitChan chan bool) {
go sws.handleSlaveEvent(quitChan)
go sws.receiveWsMsg(quitChan)
}
func (sws *LocalWsSession) handleSlaveEvent(exitCh chan bool) {
defer setQuit(exitCh)
buffer := make([]byte, 1024)
for {
select {
case <-exitCh:
return
default:
n, err := sws.slave.Read(buffer)
if err != nil {
global.LOG.Errorf("read buffer from slave failed, err: %v", err)
}
err = sws.masterWrite(buffer[:n])
if err != nil {
global.LOG.Errorf("handle master read event failed, err: %v", err)
}
}
}
}
func (sws *LocalWsSession) masterWrite(data []byte) error {
sws.writeMutex.Lock()
defer sws.writeMutex.Unlock()
err := sws.wsConn.WriteMessage(websocket.TextMessage, data)
if err != nil {
return errors.Wrapf(err, "failed to write to master")
}
return nil
}
func (sws *LocalWsSession) receiveWsMsg(exitCh chan bool) {
wsConn := sws.wsConn
defer setQuit(exitCh)
for {
select {
case <-exitCh:
return
default:
_, wsData, err := wsConn.ReadMessage()
if err != nil {
global.LOG.Errorf("reading webSocket message failed, err: %v", err)
return
}
msgObj := wsMsg{}
if err := json.Unmarshal(wsData, &msgObj); err != nil {
global.LOG.Errorf("unmarshal websocket message %s failed, err: %v", wsData, err)
}
switch msgObj.Type {
case wsMsgResize:
if msgObj.Cols > 0 && msgObj.Rows > 0 {
if err := sws.slave.ResizeTerminal(msgObj.Cols, msgObj.Rows); err != nil {
global.LOG.Errorf("ssh pty change windows size failed, err: %v", err)
}
}
case wsMsgCmd:
decodeBytes, err := base64.StdEncoding.DecodeString(msgObj.Cmd)
if err != nil {
global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err)
}
sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes)
}
}
}
}
func (sws *LocalWsSession) sendWebsocketInputCommandToSshSessionStdinPipe(cmdBytes []byte) {
if _, err := sws.slave.Write(cmdBytes); err != nil {
global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err)
}
}

View File

@ -192,10 +192,6 @@ func (sws *LogicSshWsSession) Wait(quitChan chan bool) {
} }
} }
func (sws *LogicSshWsSession) LogString() string {
return sws.logBuff.buffer.String()
}
func setQuit(ch chan bool) { func setQuit(ch chan bool) {
ch <- true ch <- true
} }

View File

@ -7244,6 +7244,11 @@
"resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz", "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz",
"integrity": "sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg==" "integrity": "sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg=="
}, },
"xterm-addon-fit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz",
"integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ=="
},
"y18n": { "y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -40,7 +40,8 @@
"vue-router": "^4.0.12", "vue-router": "^4.0.12",
"vue3-seamless-scroll": "^1.2.0", "vue3-seamless-scroll": "^1.2.0",
"xterm": "^4.19.0", "xterm": "^4.19.0",
"xterm-addon-attach": "^0.6.0" "xterm-addon-attach": "^0.6.0",
"xterm-addon-fit": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^17.0.1", "@commitlint/cli": "^17.0.1",

View File

@ -92,6 +92,7 @@ export default {
connHistory: 'historys', connHistory: 'historys',
hostHistory: 'History record', hostHistory: 'History record',
addHost: 'Add Host', addHost: 'Add Host',
localhost: 'Localhost',
name: 'Name', name: 'Name',
port: 'Port', port: 'Port',
user: 'User', user: 'User',
@ -100,6 +101,7 @@ export default {
keyMode: 'PrivateKey', keyMode: 'PrivateKey',
password: 'Password', password: 'Password',
key: 'Private Key', key: 'Private Key',
emptyTerminal: 'No terminal is currently connected',
}, },
operations: { operations: {
detail: { detail: {

View File

@ -93,6 +93,7 @@ export default {
connHistory: '历史连接', connHistory: '历史连接',
hostHistory: '历史主机信息', hostHistory: '历史主机信息',
addHost: '添加主机', addHost: '添加主机',
localhost: '本地服务器',
name: '名称', name: '名称',
port: '端口', port: '端口',
user: '用户', user: '用户',
@ -101,6 +102,7 @@ export default {
keyMode: '密钥输入', keyMode: '密钥输入',
password: '密码', password: '密码',
key: '密钥', key: '密钥',
emptyTerminal: '暂无终端连接',
}, },
operations: { operations: {
detail: { detail: {

View File

@ -12,7 +12,7 @@
v-model="terminalValue" v-model="terminalValue"
@edit="handleTabsEdit" @edit="handleTabsEdit"
> >
<el-tab-pane :key="item.name" v-for="item in terminalTabs" :label="item.title" :name="item.name"> <el-tab-pane :key="item.key" v-for="item in terminalTabs" :label="item.title" :name="item.key">
<template #label> <template #label>
<span class="custom-tabs-label"> <span class="custom-tabs-label">
<el-icon color="#67C23A" v-if="item.status === 'online'"><circleCheck /></el-icon> <el-icon color="#67C23A" v-if="item.status === 'online'"><circleCheck /></el-icon>
@ -20,24 +20,40 @@
<span> &nbsp;{{ item.title }}&nbsp;&nbsp;</span> <span> &nbsp;{{ item.title }}&nbsp;&nbsp;</span>
</span> </span>
</template> </template>
<iframe <Terminal
v-if="item.type === 'local'" style="height: calc(100vh - 265px); background-color: #000"
id="iframeTerminal" :ref="'Ref' + item.key"
name="iframeTerminal" :wsID="item.wsID"
width="100%" :terminalID="item.key"
frameborder="0" ></Terminal>
:src="item.src"
/>
<Terminal v-else :ref="'Ref' + item.name" :id="item.wsID"></Terminal>
</el-tab-pane> </el-tab-pane>
<div v-if="terminalTabs.length === 0">
<el-empty
style="background-color: #000; height: calc(100vh - 265px)"
:description="$t('terminal.emptyTerminal')"
></el-empty>
</div>
</el-tabs> </el-tabs>
</div> </div>
<el-drawer :size="320" v-model="hostDrawer" :title="$t('terminal.hostHistory')" direction="rtl"> <el-drawer :size="320" v-model="hostDrawer" :title="$t('terminal.hostHistory')" direction="rtl">
<el-button @click="onAddHost">{{ $t('terminal.addHost') }}</el-button> <el-button @click="onAddHost">{{ $t('terminal.addHost') }}</el-button>
<div v-infinite-scroll="nextPage" style="overflow: auto"> <div v-infinite-scroll="nextPage" style="overflow: auto">
<el-card
@click="onConnLocal()"
style="margin-top: 5px; cursor: pointer"
:title="$t('terminal.localhost')"
shadow="hover"
>
<div :inline="true">
<div>
<span>{{ $t('terminal.localhost') }}</span>
</div>
<span style="font-size: 14px; line-height: 25px"> [ 127.0.0.1 ]</span>
</div>
</el-card>
<div v-for="(item, index) in data" :key="item.id" @mouseover="hover = index" @mouseleave="hover = null"> <div v-for="(item, index) in data" :key="item.id" @mouseover="hover = index" @mouseleave="hover = null">
<el-card @click="onConn(item)" style="margin-top: 5px" :title="item.name" shadow="hover"> <el-card @click="onConn(item)" style="margin-top: 5px; cursor: pointer" shadow="hover">
<div :inline="true"> <div :inline="true">
<div> <div>
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
@ -79,7 +95,7 @@
<el-input v-model="hostInfo.addr" style="width: 80%" /> <el-input v-model="hostInfo.addr" style="width: 80%" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('terminal.port')" prop="port"> <el-form-item :label="$t('terminal.port')" prop="port">
<el-input v-model="hostInfo.port" style="width: 80%" /> <el-input v-model.number="hostInfo.port" style="width: 80%" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('terminal.user')" prop="user"> <el-form-item :label="$t('terminal.user')" prop="user">
<el-input v-model="hostInfo.user" style="width: 80%" /> <el-input v-model="hostInfo.user" style="width: 80%" />
@ -90,13 +106,8 @@
<el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio> <el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item <el-form-item :label="$t('terminal.password')" v-if="hostInfo.authMode === 'password'" prop="password">
:label="$t('terminal.password')" <el-input show-password type="password" v-model="hostInfo.password" style="width: 80%" />
show-password
v-if="hostInfo.authMode === 'password'"
prop="password"
>
<el-input type="password" v-model="hostInfo.password" style="width: 80%" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey"> <el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
<el-input type="textarea" v-model="hostInfo.privateKey" style="width: 80%" /> <el-input type="textarea" v-model="hostInfo.privateKey" style="width: 80%" />
@ -133,12 +144,13 @@ let timer: NodeJS.Timer | null = null;
const terminalValue = ref(); const terminalValue = ref();
const terminalTabs = ref([]) as any; const terminalTabs = ref([]) as any;
let tabIndex = 0;
const data = ref(); const data = ref();
const hostDrawer = ref(false); const hostDrawer = ref(false);
const paginationConfig = reactive({ const paginationConfig = reactive({
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 8,
total: 0, total: 0,
}); });
@ -175,6 +187,9 @@ const handleTabsEdit = (targetName: string, action: 'remove' | 'add') => {
if (action === 'add') { if (action === 'add') {
connVisiable.value = true; connVisiable.value = true;
operation.value = 'conn'; operation.value = 'conn';
if (hostInfoRef.value) {
hostInfoRef.value.resetFields();
}
} else if (action === 'remove') { } else if (action === 'remove') {
if (ctx) { if (ctx) {
ctx.refs[`Ref${targetName}`] && ctx.refs[`Ref${targetName}`][0].onClose(); ctx.refs[`Ref${targetName}`] && ctx.refs[`Ref${targetName}`][0].onClose();
@ -183,22 +198,23 @@ const handleTabsEdit = (targetName: string, action: 'remove' | 'add') => {
let activeName = terminalValue.value; let activeName = terminalValue.value;
if (activeName === targetName) { if (activeName === targetName) {
tabs.forEach((tab: any, index: any) => { tabs.forEach((tab: any, index: any) => {
if (tab.name === targetName) { if (tab.key === targetName) {
const nextTab = tabs[index + 1] || tabs[index - 1]; const nextTab = tabs[index + 1] || tabs[index - 1];
if (nextTab) { if (nextTab) {
activeName = nextTab.name; activeName = nextTab.key;
} }
} }
}); });
} }
terminalValue.value = activeName; terminalValue.value = activeName;
terminalTabs.value = tabs.filter((tab: any) => tab.name !== targetName); terminalTabs.value = tabs.filter((tab: any) => tab.key !== targetName);
} }
}; };
const loadHost = async () => { const loadHost = async () => {
const res = await getHostList({ page: paginationConfig.currentPage, pageSize: paginationConfig.pageSize }); const res = await getHostList({ page: paginationConfig.currentPage, pageSize: paginationConfig.pageSize });
data.value = res.data.items; data.value = res.data.items;
paginationConfig.total = res.data.total;
}; };
const nextPage = () => { const nextPage = () => {
@ -247,17 +263,15 @@ const submitAddHost = (formEl: FormInstance | undefined) => {
case 'conn': case 'conn':
const res = await addHost(hostInfo); const res = await addHost(hostInfo);
terminalTabs.value.push({ terminalTabs.value.push({
name: res.data.addr, key: `${res.data.addr}-${++tabIndex}`,
title: res.data.addr, title: res.data.addr,
wsID: res.data.id, wsID: res.data.id,
type: 'remote',
status: 'online', status: 'online',
}); });
terminalValue.value = res.data.addr; terminalValue.value = `${res.data.addr}-${tabIndex}`;
} }
connVisiable.value = false; connVisiable.value = false;
loadHost(); loadHost();
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
} catch (error) { } catch (error) {
ElMessage.success(i18n.global.t('commons.msg.loginSuccess') + ':' + error); ElMessage.success(i18n.global.t('commons.msg.loginSuccess') + ':' + error);
} }
@ -266,13 +280,23 @@ const submitAddHost = (formEl: FormInstance | undefined) => {
const onConn = (row: Host.Host) => { const onConn = (row: Host.Host) => {
terminalTabs.value.push({ terminalTabs.value.push({
name: row.addr, key: `${row.addr}-${++tabIndex}`,
title: row.addr, title: row.addr,
wsID: row.id, wsID: row.id,
type: 'remote',
status: 'online', status: 'online',
}); });
terminalValue.value = row.addr; terminalValue.value = `${row.addr}-${tabIndex}`;
hostDrawer.value = false;
};
const onConnLocal = () => {
terminalTabs.value.push({
key: `127.0.0.1-${++tabIndex}`,
title: '127.0.0.1',
wsID: 0,
status: 'online',
});
terminalValue.value = `127.0.0.1-${tabIndex}`;
hostDrawer.value = false; hostDrawer.value = false;
}; };
@ -291,24 +315,14 @@ function changeFrameHeight() {
function syncTerminal() { function syncTerminal() {
for (const terminal of terminalTabs.value) { for (const terminal of terminalTabs.value) {
if (terminal.type === 'remote') { if (ctx && ctx.refs[`Ref${terminal.key}`]) {
if (ctx && ctx.refs[`Ref${terminal.name}`]) { terminal.status = ctx.refs[`Ref${terminal.key}`][0].isWsOpen() ? 'online' : 'closed';
terminal.status = ctx.refs[`Ref${terminal.name}`][0].isWsOpen() ? 'online' : 'closed';
console.log(terminal.status);
}
} }
} }
} }
onMounted(() => { onMounted(() => {
terminalTabs.value.push({ onConnLocal();
name: '127.0.0.1',
title: '127.0.0.1',
src: 'http://localhost:8080',
type: 'local',
status: 'online',
});
terminalValue.value = '127.0.0.1';
nextTick(() => { nextTick(() => {
changeFrameHeight(); changeFrameHeight();
window.addEventListener('resize', changeFrameHeight); window.addEventListener('resize', changeFrameHeight);
@ -341,27 +355,27 @@ onBeforeMount(() => {
cursor: pointer; cursor: pointer;
} }
.el-tabs { .el-tabs {
::v-deep .el-tabs__header { :deep .el-tabs__header {
padding: 0; padding: 0;
position: relative; position: relative;
margin: 0 0 3px 0; margin: 0 0 3px 0;
} }
::v-deep .el-tabs__nav { :deep .el-tabs__nav {
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
transition: transform var(--el-transition-duration); transition: transform var(--el-transition-duration);
float: left; float: left;
z-index: calc(var(--el-index-normal) + 1); z-index: calc(var(--el-index-normal) + 1);
} }
::v-deep .el-tabs__item { :deep .el-tabs__item {
color: #575758; color: #575758;
padding: 0 0px; padding: 0 0px;
} }
::v-deep .el-tabs__item.is-active { :deep .el-tabs__item.is-active {
color: #ebeef5; color: #ebeef5;
background-color: #575758; background-color: #575758;
} }
::v-deep .el-tabs__new-tab { :deep .el-tabs__new-tab {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,5 @@
<template> <template>
<div :id="'terminal' + props.id"></div> <div :id="'terminal' + props.terminalID"></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -8,13 +8,17 @@ import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach'; import { AttachAddon } from 'xterm-addon-attach';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import { FitAddon } from 'xterm-addon-fit';
interface WsProps { interface WsProps {
id: number; terminalID: string;
wsID: number;
} }
const props = withDefaults(defineProps<WsProps>(), { const props = withDefaults(defineProps<WsProps>(), {
id: 0, terminalID: '',
wsID: 0,
}); });
const fitAddon = new FitAddon();
const loading = ref(true); const loading = ref(true);
let terminalSocket = ref(null) as unknown as WebSocket; let terminalSocket = ref(null) as unknown as WebSocket;
let term = ref(null) as unknown as Terminal; let term = ref(null) as unknown as Terminal;
@ -54,7 +58,7 @@ const closeRealTerminal = (ev: CloseEvent) => {
}; };
const initTerm = () => { const initTerm = () => {
let ifm = document.getElementById('terminal' + props.id) as HTMLInputElement | null; let ifm = document.getElementById('terminal' + props.terminalID) as HTMLInputElement | null;
term = new Terminal({ term = new Terminal({
lineHeight: 1.2, lineHeight: 1.2,
fontSize: 12, fontSize: 12,
@ -66,15 +70,19 @@ const initTerm = () => {
cursorStyle: 'underline', cursorStyle: 'underline',
scrollback: 100, scrollback: 100,
tabStopWidth: 4, tabStopWidth: 4,
cols: ifm ? Math.floor(document.documentElement.clientWidth / 7) : 200,
rows: ifm ? Math.floor(document.documentElement.clientHeight / 20) : 25,
}); });
if (ifm) { if (ifm) {
term.open(ifm); term.open(ifm);
term.write('\n'); term.write('\n');
if (props.wsID === 0) {
terminalSocket = new WebSocket( terminalSocket = new WebSocket(
`ws://localhost:9999/api/v1/terminals?id=${props.id}&cols=${term.cols}&rows=${term.rows}`, `ws://localhost:9999/api/v1/terminals/local?cols=${term.cols}&rows=${term.rows}`,
); );
} else {
terminalSocket = new WebSocket(
`ws://localhost:9999/api/v1/terminals?id=${props.wsID}&cols=${term.cols}&rows=${term.rows}`,
);
}
terminalSocket.onopen = runRealTerminal; terminalSocket.onopen = runRealTerminal;
terminalSocket.onmessage = onWSReceive; terminalSocket.onmessage = onWSReceive;
terminalSocket.onclose = closeRealTerminal; terminalSocket.onclose = closeRealTerminal;
@ -90,7 +98,24 @@ const initTerm = () => {
} }
}); });
term.loadAddon(new AttachAddon(terminalSocket)); term.loadAddon(new AttachAddon(terminalSocket));
term.loadAddon(fitAddon);
setTimeout(() => {
fitAddon.fit();
if (isWsOpen()) {
terminalSocket.send(
JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}),
);
} }
}, 30);
}
};
const fitTerm = () => {
fitAddon.fit();
}; };
const isWsOpen = () => { const isWsOpen = () => {
@ -105,20 +130,18 @@ function onClose() {
} }
function changeTerminalSize() { function changeTerminalSize() {
let ifm = document.getElementById('terminal' + props.id) as HTMLInputElement | null; fitTerm();
if (ifm) { const { cols, rows } = term;
ifm.style.height = document.documentElement.clientHeight - 300 + 'px';
if (isWsOpen()) { if (isWsOpen()) {
terminalSocket.send( terminalSocket.send(
JSON.stringify({ JSON.stringify({
type: 'resize', type: 'resize',
cols: Math.floor(document.documentElement.clientWidth / 7), cols: cols,
rows: Math.floor(document.documentElement.clientHeight / 20), rows: rows,
}), }),
); );
} }
} }
}
defineExpose({ defineExpose({
onClose, onClose,
@ -128,7 +151,6 @@ defineExpose({
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
initTerm(); initTerm();
changeTerminalSize();
window.addEventListener('resize', changeTerminalSize); window.addEventListener('resize', changeTerminalSize);
}); });
}); });