mirror of
https://github.com/1Panel-dev/1Panel
synced 2024-11-22 16:10:22 +00:00
fix: 终端本地连接方式修改
This commit is contained in:
parent
0fea645692
commit
bdecd777a7
@ -79,48 +79,6 @@ 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 {
|
||||
if err != nil {
|
||||
global.LOG.Errorf("handler ws faled:, err: %v", err)
|
||||
|
@ -4,7 +4,7 @@ type Host struct {
|
||||
BaseModel
|
||||
GroupBelong string `gorm:"type:varchar(64);not null" json:"groupBelong"`
|
||||
Name string `gorm:"type:varchar(64);unique;not null" json:"name"`
|
||||
Addr string `gorm:"type:varchar(16);unique;not null" json:"addr"`
|
||||
Addr string `gorm:"type:varchar(16);not null" json:"addr"`
|
||||
Port int `gorm:"type:varchar(5);not null" json:"port"`
|
||||
User string `gorm:"type:varchar(64);not null" json:"user"`
|
||||
AuthMode string `gorm:"type:varchar(16);not null" json:"authMode"`
|
||||
|
@ -12,6 +12,9 @@ type IHostRepo interface {
|
||||
Get(opts ...DBOption) (model.Host, error)
|
||||
GetList(opts ...DBOption) ([]model.Host, error)
|
||||
WithByInfo(info string) DBOption
|
||||
WithByPort(port uint) DBOption
|
||||
WithByUser(user string) DBOption
|
||||
WithByAddr(addr string) DBOption
|
||||
Create(host *model.Host) error
|
||||
ChangeGroup(oldGroup, newGroup string) error
|
||||
Update(id uint, vars map[string]interface{}) error
|
||||
@ -52,6 +55,22 @@ func (c *HostRepo) WithByInfo(info string) DBOption {
|
||||
}
|
||||
}
|
||||
|
||||
func (u *HostRepo) WithByPort(port uint) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("port = ?", port)
|
||||
}
|
||||
}
|
||||
func (u *HostRepo) WithByUser(user string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("user = ?", user)
|
||||
}
|
||||
}
|
||||
func (u *HostRepo) WithByAddr(addr string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("addr = ?", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *HostRepo) Create(host *model.Host) error {
|
||||
return global.DB.Create(host).Error
|
||||
}
|
||||
|
@ -57,14 +57,43 @@ func (u *HostService) SearchForTree(search dto.SearchForTree) ([]dto.HostTree, e
|
||||
return datas, err
|
||||
}
|
||||
|
||||
func (u *HostService) Create(hostDto dto.HostOperate) (*dto.HostInfo, error) {
|
||||
host, _ := hostRepo.Get(commonRepo.WithByName(hostDto.Name))
|
||||
func (u *HostService) Create(req dto.HostOperate) (*dto.HostInfo, error) {
|
||||
host, _ := hostRepo.Get(commonRepo.WithByName(req.Name))
|
||||
if host.ID != 0 {
|
||||
return nil, constant.ErrRecordExist
|
||||
}
|
||||
if err := copier.Copy(&host, &hostDto); err != nil {
|
||||
if err := copier.Copy(&host, &req); err != nil {
|
||||
return nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
|
||||
}
|
||||
var sameHostID uint
|
||||
if req.Addr == "127.0.0.1" {
|
||||
hostSame, _ := hostRepo.Get(hostRepo.WithByAddr(req.Addr))
|
||||
sameHostID = hostSame.ID
|
||||
} else {
|
||||
hostSame, _ := hostRepo.Get(hostRepo.WithByAddr(req.Addr), hostRepo.WithByUser(req.User), hostRepo.WithByPort(req.Port))
|
||||
sameHostID = hostSame.ID
|
||||
}
|
||||
if sameHostID != 0 {
|
||||
host.ID = sameHostID
|
||||
upMap := make(map[string]interface{})
|
||||
upMap["name"] = req.Name
|
||||
upMap["group_belong"] = req.GroupBelong
|
||||
upMap["addr"] = req.Addr
|
||||
upMap["port"] = req.Port
|
||||
upMap["user"] = req.User
|
||||
upMap["auth_mode"] = req.AuthMode
|
||||
upMap["password"] = req.Password
|
||||
upMap["private_key"] = req.PrivateKey
|
||||
if err := hostRepo.Update(sameHostID, upMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hostinfo dto.HostInfo
|
||||
if err := copier.Copy(&hostinfo, &host); err != nil {
|
||||
return nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
|
||||
}
|
||||
return &hostinfo, nil
|
||||
}
|
||||
|
||||
if err := hostRepo.Create(&host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -17,6 +17,5 @@ func (s *TerminalRouter) InitTerminalRouter(Router *gin.RouterGroup) {
|
||||
baseApi := v1.ApiGroupApp.BaseApi
|
||||
{
|
||||
terminalRouter.GET("", baseApi.WsSsh)
|
||||
terminalRouter.GET("/local", baseApi.LocalWsSsh)
|
||||
}
|
||||
}
|
||||
|
@ -1,115 +0,0 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/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)
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/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, _ := sws.slave.Read(buffer)
|
||||
_ = sws.masterWrite(buffer[:n])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
_ = json.Unmarshal(wsData, &msgObj)
|
||||
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)
|
||||
}
|
||||
}
|
@ -29,27 +29,7 @@ const hostRouter = {
|
||||
{
|
||||
path: '/hosts/terminal',
|
||||
name: 'Terminal',
|
||||
component: () => import('@/views/host/terminal/terminal/index.vue'),
|
||||
meta: {
|
||||
title: 'menu.terminal',
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/hosts/host',
|
||||
name: 'TerminalHost',
|
||||
hidden: true,
|
||||
component: () => import('@/views/host/terminal/host/index.vue'),
|
||||
meta: {
|
||||
title: 'menu.terminal',
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/hosts/command',
|
||||
name: 'TerminalCommand',
|
||||
hidden: true,
|
||||
component: () => import('@/views/host/terminal/command/index.vue'),
|
||||
component: () => import('@/views/host/terminal/index.vue'),
|
||||
meta: {
|
||||
title: 'menu.terminal',
|
||||
keepAlive: true,
|
||||
|
@ -170,7 +170,7 @@ const buttons = [
|
||||
onEdit(row);
|
||||
},
|
||||
disabled: (row: any) => {
|
||||
return row.createdBy !== 'local';
|
||||
return row.createdBy !== 'Local';
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -179,7 +179,7 @@ const buttons = [
|
||||
onDelete(row);
|
||||
},
|
||||
disabled: (row: any) => {
|
||||
return row.createdBy === 'apps';
|
||||
return row.createdBy === 'Apps';
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<Submenu activeName="command" />
|
||||
<el-card style="margin-top: 20px">
|
||||
<ComplexTable :pagination-config="paginationConfig" v-model:selects="selects" :data="data" @search="search">
|
||||
<template #toolbar>
|
||||
@ -40,10 +39,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComplexTable from '@/components/complex-table/index.vue';
|
||||
import Submenu from '@/views/host/terminal/index.vue';
|
||||
import { Command } from '@/api/interface/command';
|
||||
import { addCommand, editCommand, deleteCommand, getCommandPage } from '@/api/modules/command';
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useDeleteData } from '@/hooks/use-delete-data';
|
||||
import type { ElForm } from 'element-plus';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
@ -148,7 +146,10 @@ const search = async () => {
|
||||
paginationConfig.total = res.data.total;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
function onInit() {
|
||||
search();
|
||||
}
|
||||
defineExpose({
|
||||
onInit,
|
||||
});
|
||||
</script>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<Submenu activeName="host" />
|
||||
<el-row class="row-box" style="margin-top: 20px" :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-card class="el-card">
|
||||
@ -142,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, reactive } from 'vue';
|
||||
import type { ElForm } from 'element-plus';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { Host } from '@/api/interface/host';
|
||||
@ -153,7 +152,6 @@ import { useDeleteData } from '@/hooks/use-delete-data';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import Submenu from '@/views/host/terminal/index.vue';
|
||||
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
const hostInfoRef = ref<FormInstance>();
|
||||
@ -311,9 +309,12 @@ const onEdit = async (node: Node, data: Tree) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
function onInit() {
|
||||
loadHostTree();
|
||||
loadGroups();
|
||||
}
|
||||
defineExpose({
|
||||
onInit,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -1,46 +1,459 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="topCard">
|
||||
<el-radio-group v-model="active">
|
||||
<el-radio-button class="topButton" size="large" @click="routerTo('/hosts/terminal')" label="terminal">
|
||||
<el-radio-group @change="handleChange" v-model="activeNames">
|
||||
<el-radio-button class="topButton" size="default" label="terminal">
|
||||
{{ $t('menu.terminal') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button class="topButton" size="large" @click="routerTo('/hosts/host')" label="host">
|
||||
<el-radio-button class="topButton" size="default" label="host">
|
||||
{{ $t('menu.host') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button class="topButton" size="large" @click="routerTo('/hosts/command')" label="command">
|
||||
<el-radio-button class="topButton" size="default" label="command">
|
||||
{{ $t('terminal.quickCommand') }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-card>
|
||||
<div v-show="activeNames === 'terminal'">
|
||||
<el-tabs
|
||||
type="card"
|
||||
class="terminal-tabs"
|
||||
style="background-color: #efefef; margin-top: 20px"
|
||||
v-model="terminalValue"
|
||||
:before-leave="beforeLeave"
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
:key="item.key"
|
||||
v-for="item in terminalTabs"
|
||||
:closable="true"
|
||||
:label="item.title"
|
||||
:name="item.key"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
<el-icon style="margin-top: 1px" color="#67C23A" v-if="item.status === 'online'">
|
||||
<circleCheck />
|
||||
</el-icon>
|
||||
<el-icon style="margin-top: 1px" color="#F56C6C" v-if="item.status === 'closed'">
|
||||
<circleClose />
|
||||
</el-icon>
|
||||
<span> {{ item.title }} </span>
|
||||
</span>
|
||||
</template>
|
||||
<Terminal
|
||||
style="height: calc(100vh - 178px); background-color: #000"
|
||||
:ref="'Ref' + item.key"
|
||||
:wsID="item.wsID"
|
||||
:terminalID="item.key"
|
||||
></Terminal>
|
||||
<div>
|
||||
<el-select
|
||||
v-model="quickCmd"
|
||||
clearable
|
||||
filterable
|
||||
@change="quickInput"
|
||||
style="width: 25%"
|
||||
:placeholder="$t('terminal.quickCommand')"
|
||||
>
|
||||
<el-option
|
||||
v-for="cmd in commandList"
|
||||
:key="cmd.id"
|
||||
:label="cmd.name + ' [ ' + cmd.command + ' ] '"
|
||||
:value="cmd.command"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
:placeholder="$t('terminal.batchInput')"
|
||||
v-model="batchVal"
|
||||
@keyup.enter="batchInput"
|
||||
style="width: 75%"
|
||||
>
|
||||
<template #append>
|
||||
<el-switch v-model="isBatch" class="ml-2" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :closable="false" name="newTabs">
|
||||
<template #label>
|
||||
<el-button
|
||||
v-popover="popoverRef"
|
||||
style="background-color: #ededed; border: 0"
|
||||
icon="Plus"
|
||||
></el-button>
|
||||
<el-popover ref="popoverRef" width="250px" trigger="hover" virtual-triggering persistent>
|
||||
<el-button-group style="width: 100%">
|
||||
<el-button @click="onNewSsh">New ssh</el-button>
|
||||
<el-button @click="onConnLocal">New tab</el-button>
|
||||
</el-button-group>
|
||||
<el-input clearable style="margin-top: 5px" v-model="hostfilterInfo">
|
||||
<template #append><el-button icon="search" /></template>
|
||||
</el-input>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:expand-on-click-node="false"
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:data="hostTree"
|
||||
:props="defaultProps"
|
||||
:filter-node-method="filterHost"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<span>
|
||||
<a @click="onConn(node, data)">{{ node.label }}</a>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<div v-if="terminalTabs.length === 0">
|
||||
<el-empty
|
||||
style="background-color: #000; height: calc(100vh - 150px)"
|
||||
:description="$t('terminal.emptyTerminal')"
|
||||
></el-empty>
|
||||
</div>
|
||||
</el-tabs>
|
||||
<el-button @click="toggleFullscreen" class="fullScreen" icon="FullScreen"></el-button>
|
||||
</div>
|
||||
<div v-if="activeNames === 'host'"><HostTab ref="hostTabRef" /></div>
|
||||
<div v-if="activeNames === 'command'"><CommandTab ref="commandTabRef" /></div>
|
||||
|
||||
<el-dialog v-model="connVisiable" :title="$t('terminal.addHost')" width="30%">
|
||||
<el-form ref="hostInfoRef" label-width="100px" label-position="left" :model="hostInfo" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP" prop="addr">
|
||||
<el-input clearable v-model="hostInfo.addr" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.user')" prop="user">
|
||||
<el-input clearable v-model="hostInfo.user" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.authMode')" prop="authMode">
|
||||
<el-radio-group v-model="hostInfo.authMode">
|
||||
<el-radio label="password">{{ $t('terminal.passwordMode') }}</el-radio>
|
||||
<el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.password')" v-if="hostInfo.authMode === 'password'" prop="password">
|
||||
<el-input clearable show-password type="password" v-model="hostInfo.password" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.privateKey" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="connVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button @click="submitAddHost(hostInfoRef, 'testConn')">
|
||||
{{ $t('terminal.testConn') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitAddHost(hostInfoRef, 'saveAndConn')">
|
||||
{{ $t('terminal.saveAndConn') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
interface MenuProps {
|
||||
activeName: string;
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeMount, ref, watch, reactive, getCurrentInstance } from 'vue';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { testConn, getHostTree, addHost } from '@/api/modules/host';
|
||||
import { getCommandList } from '@/api/modules/command';
|
||||
import i18n from '@/lang';
|
||||
import { ElForm } from 'element-plus';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import Terminal from '@/views/host/terminal/terminal/index.vue';
|
||||
import HostTab from '@/views/host/terminal/host/index.vue';
|
||||
import CommandTab from '@/views/host/terminal/command/index.vue';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import { ElTree } from 'element-plus';
|
||||
import screenfull from 'screenfull';
|
||||
|
||||
let timer: NodeJS.Timer | null = null;
|
||||
|
||||
const activeNames = ref<string>('terminal');
|
||||
const hostTabRef = ref();
|
||||
const commandTabRef = ref();
|
||||
|
||||
const terminalValue = ref();
|
||||
const terminalTabs = ref([]) as any;
|
||||
let tabIndex = 0;
|
||||
const commandList = ref();
|
||||
let quickCmd = ref();
|
||||
let batchVal = ref();
|
||||
let isBatch = ref<boolean>(false);
|
||||
const popoverRef = ref();
|
||||
|
||||
const connVisiable = ref<boolean>(false);
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
|
||||
const hostInfoRef = ref<FormInstance>();
|
||||
const hostTree = ref<Array<Host.HostTree>>();
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const defaultProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
};
|
||||
const hostfilterInfo = ref('');
|
||||
interface Tree {
|
||||
id: number;
|
||||
label: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
const props = withDefaults(defineProps<MenuProps>(), {
|
||||
activeName: 'terminal',
|
||||
const rules = reactive({
|
||||
name: [Rules.requiredInput, Rules.name],
|
||||
addr: [Rules.requiredInput, Rules.ip],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
user: [Rules.requiredInput],
|
||||
authMode: [Rules.requiredSelect],
|
||||
password: [Rules.requiredInput],
|
||||
privateKey: [Rules.requiredInput],
|
||||
});
|
||||
|
||||
const active = ref('terminal');
|
||||
const localHostID = ref();
|
||||
|
||||
let hostInfo = reactive<Host.HostOperate>({
|
||||
id: 0,
|
||||
name: '',
|
||||
groupBelong: '',
|
||||
addr: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
authMode: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const ctx = getCurrentInstance() as any;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.toggle();
|
||||
}
|
||||
}
|
||||
const handleChange = (tab: any) => {
|
||||
if (tab === 'host') {
|
||||
hostTabRef.value!.onInit();
|
||||
}
|
||||
if (tab === 'command') {
|
||||
commandTabRef.value!.onInit();
|
||||
}
|
||||
if (tab === 'terminal') {
|
||||
loadCommand();
|
||||
loadHost();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabsRemove = (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
return;
|
||||
}
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${targetName}`] && ctx.refs[`Ref${targetName}`][0].onClose();
|
||||
}
|
||||
const tabs = terminalTabs.value;
|
||||
let activeName = terminalValue.value;
|
||||
if (activeName === targetName) {
|
||||
tabs.forEach((tab: any, index: any) => {
|
||||
if (tab.key === targetName) {
|
||||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||||
if (nextTab) {
|
||||
activeName = nextTab.key;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
terminalValue.value = activeName;
|
||||
terminalTabs.value = tabs.filter((tab: any) => tab.key !== targetName);
|
||||
};
|
||||
|
||||
const loadHost = async () => {
|
||||
const res = await getHostTree({});
|
||||
hostTree.value = res.data;
|
||||
for (const item of hostTree.value) {
|
||||
for (const host of item.children) {
|
||||
if (host.label.indexOf('127.0.0.1')) {
|
||||
localHostID.value = host.id;
|
||||
if (terminalTabs.value.length === 0) {
|
||||
onConnLocal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
hostInfo.name = 'localhost';
|
||||
hostInfo.addr = '127.0.0.1';
|
||||
connVisiable.value = true;
|
||||
};
|
||||
watch(hostfilterInfo, (val: any) => {
|
||||
treeRef.value!.filter(val);
|
||||
});
|
||||
const filterHost = (value: string, data: any) => {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
};
|
||||
const loadCommand = async () => {
|
||||
const res = await getCommandList();
|
||||
commandList.value = res.data;
|
||||
};
|
||||
|
||||
function quickInput(val: any) {
|
||||
if (val !== '') {
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(val + '\n');
|
||||
quickCmd.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function batchInput() {
|
||||
if (batchVal.value === '' || !ctx) {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`Ref${tab.key}`] && ctx.refs[`Ref${tab.key}`][0].onSendMsg(batchVal.value + '\n');
|
||||
}
|
||||
batchVal.value = '';
|
||||
return;
|
||||
}
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(batchVal.value + '\n');
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
function beforeLeave(activeName: string) {
|
||||
if (activeName === 'newTabs') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onNewSsh = () => {
|
||||
connVisiable.value = true;
|
||||
if (hostInfoRef.value) {
|
||||
hostInfoRef.value.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
const onConn = (node: Node, data: Tree) => {
|
||||
if (node.level === 1) {
|
||||
return;
|
||||
}
|
||||
let addr = data.label.split('@')[1].split(':')[0];
|
||||
terminalTabs.value.push({
|
||||
key: `${addr}-${++tabIndex}`,
|
||||
title: addr,
|
||||
wsID: data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${addr}-${tabIndex}`;
|
||||
};
|
||||
|
||||
const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
|
||||
if (!formEl) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
hostInfo.groupBelong = 'default';
|
||||
switch (ops) {
|
||||
case 'testConn':
|
||||
await testConn(hostInfo);
|
||||
ElMessage.success(i18n.global.t('terminal.connTestOk'));
|
||||
break;
|
||||
case 'saveAndConn':
|
||||
const res = await addHost(hostInfo);
|
||||
terminalTabs.value.push({
|
||||
key: `${res.data.addr}-${++tabIndex}`,
|
||||
title: res.data.addr,
|
||||
wsID: res.data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${res.data.addr}-${tabIndex}`;
|
||||
connVisiable.value = false;
|
||||
loadHost();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onConnLocal = () => {
|
||||
terminalTabs.value.push({
|
||||
key: `127.0.0.1-${++tabIndex}`,
|
||||
title: i18n.global.t('terminal.localhost'),
|
||||
wsID: localHostID.value,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `127.0.0.1-${tabIndex}`;
|
||||
};
|
||||
|
||||
function syncTerminal() {
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`Ref${terminal.key}`][0]) {
|
||||
terminal.status = ctx.refs[`Ref${terminal.key}`][0].isWsOpen() ? 'online' : 'closed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.activeName) {
|
||||
active.value = props.activeName;
|
||||
}
|
||||
loadHost();
|
||||
loadCommand();
|
||||
timer = setInterval(() => {
|
||||
syncTerminal();
|
||||
}, 1000 * 8);
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
clearInterval(Number(timer));
|
||||
timer = null;
|
||||
});
|
||||
|
||||
const routerTo = (path: string) => {
|
||||
router.push({ path: path });
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.terminal-tabs {
|
||||
:deep .el-tabs__header {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
::deep .el-tabs__nav {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
transition: transform var(--el-transition-duration);
|
||||
float: left;
|
||||
z-index: calc(var(--el-index-normal) + 1);
|
||||
}
|
||||
:deep .el-tabs__item {
|
||||
color: #575758;
|
||||
padding: 0 0px;
|
||||
}
|
||||
:deep .el-tabs__item.is-active {
|
||||
color: #ebeef5;
|
||||
background-color: #575758;
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
.vertical-tabs > .el-tabs__content {
|
||||
padding: 32px;
|
||||
color: #6b778c;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fullScreen {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 86px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.el-tabs--top.el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
|
||||
padding-right: 0px;
|
||||
}
|
||||
.topCard {
|
||||
--el-card-border-color: var(--el-border-color-light);
|
||||
--el-card-border-radius: 4px;
|
||||
|
@ -1,423 +1,172 @@
|
||||
<template>
|
||||
<div>
|
||||
<Submenu activeName="terminal" />
|
||||
<el-card class="topCard" style="margin-top: 20px">
|
||||
<el-tabs
|
||||
type="card"
|
||||
class="terminal-tabs"
|
||||
style="background-color: #efefef"
|
||||
v-model="terminalValue"
|
||||
:before-leave="beforeLeave"
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
:key="item.key"
|
||||
v-for="item in terminalTabs"
|
||||
:closable="true"
|
||||
:label="item.title"
|
||||
:name="item.key"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
<el-icon style="margin-top: 1px" color="#67C23A" v-if="item.status === 'online'">
|
||||
<circleCheck />
|
||||
</el-icon>
|
||||
<el-icon style="margin-top: 1px" color="#F56C6C" v-if="item.status === 'closed'">
|
||||
<circleClose />
|
||||
</el-icon>
|
||||
<span> {{ item.title }} </span>
|
||||
</span>
|
||||
</template>
|
||||
<Terminal
|
||||
style="height: calc(100vh - 178px); background-color: #000"
|
||||
:ref="'Ref' + item.key"
|
||||
:wsID="item.wsID"
|
||||
:terminalID="item.key"
|
||||
></Terminal>
|
||||
<div>
|
||||
<el-select
|
||||
v-model="quickCmd"
|
||||
clearable
|
||||
filterable
|
||||
@change="quickInput(quickCmd)"
|
||||
style="width: 25%"
|
||||
:placeholder="$t('terminal.quickCommand')"
|
||||
>
|
||||
<el-option
|
||||
v-for="cmd in commandList"
|
||||
:key="cmd.id"
|
||||
:label="cmd.name + ' [ ' + cmd.command + ' ] '"
|
||||
:value="cmd.command"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
:placeholder="$t('terminal.batchInput')"
|
||||
v-model="batchVal"
|
||||
@keyup.enter="batchInput"
|
||||
style="width: 75%"
|
||||
>
|
||||
<template #append>
|
||||
<el-switch v-model="isBatch" class="ml-2" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :closable="false" name="newTabs">
|
||||
<template #label>
|
||||
<el-button
|
||||
v-popover="popoverRef"
|
||||
style="background-color: #ededed; border: 0"
|
||||
icon="Plus"
|
||||
></el-button>
|
||||
<el-popover ref="popoverRef" width="250px" trigger="hover" virtual-triggering persistent>
|
||||
<el-button-group style="width: 100%">
|
||||
<el-button @click="onNewSsh">New ssh</el-button>
|
||||
<el-button @click="onNewTab">New tab</el-button>
|
||||
</el-button-group>
|
||||
<el-input clearable style="margin-top: 5px" v-model="hostfilterInfo">
|
||||
<template #append><el-button icon="search" /></template>
|
||||
</el-input>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:expand-on-click-node="false"
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:data="hostTree"
|
||||
:props="defaultProps"
|
||||
:filter-node-method="filterHost"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<span>
|
||||
<a @click="onConn(node, data)">{{ node.label }}</a>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<div v-if="terminalTabs.length === 0">
|
||||
<el-empty
|
||||
style="background-color: #000; height: calc(100vh - 150px)"
|
||||
:description="$t('terminal.emptyTerminal')"
|
||||
></el-empty>
|
||||
</div>
|
||||
</el-tabs>
|
||||
<el-button @click="toggleFullscreen" class="fullScreen" icon="FullScreen"></el-button>
|
||||
</el-card>
|
||||
<el-dialog v-model="connVisiable" :title="$t('terminal.addHost')" width="30%">
|
||||
<el-form ref="hostInfoRef" label-width="100px" label-position="left" :model="hostInfo" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP" prop="addr">
|
||||
<el-input clearable v-model="hostInfo.addr" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.user')" prop="user">
|
||||
<el-input clearable v-model="hostInfo.user" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.authMode')" prop="authMode">
|
||||
<el-radio-group v-model="hostInfo.authMode">
|
||||
<el-radio label="password">{{ $t('terminal.passwordMode') }}</el-radio>
|
||||
<el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.password')" v-if="hostInfo.authMode === 'password'" prop="password">
|
||||
<el-input clearable show-password type="password" v-model="hostInfo.password" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.privateKey" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="connVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button @click="submitAddHost(hostInfoRef, 'testConn')">
|
||||
{{ $t('terminal.testConn') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitAddHost(hostInfoRef, 'saveAndConn')">
|
||||
{{ $t('terminal.saveAndConn') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div :id="'terminal' + props.terminalID"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Submenu from '@/views/host/terminal/index.vue';
|
||||
import Terminal from '@/views/host/terminal/terminal/terminal.vue';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { getCommandList } from '@/api/modules/command';
|
||||
import { addHost, getHostTree, testConn } from '@/api/modules/host';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import i18n from '@/lang';
|
||||
import { ElForm, ElMessage, ElTree } from 'element-plus';
|
||||
import screenfull from 'screenfull';
|
||||
import { getCurrentInstance, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { Terminal } from 'xterm';
|
||||
import { AttachAddon } from 'xterm-addon-attach';
|
||||
import { Base64 } from 'js-base64';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
let timer: NodeJS.Timer | null = null;
|
||||
const terminalValue = ref();
|
||||
const terminalTabs = ref([]) as any;
|
||||
let tabIndex = 0;
|
||||
const commandList = ref();
|
||||
let quickCmd = ref();
|
||||
let batchVal = ref();
|
||||
let isBatch = ref<boolean>(false);
|
||||
const popoverRef = ref();
|
||||
interface WsProps {
|
||||
terminalID: string;
|
||||
wsID: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<WsProps>(), {
|
||||
terminalID: '',
|
||||
wsID: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const loading = ref(true);
|
||||
let terminalSocket = ref(null) as unknown as WebSocket;
|
||||
let term = ref(null) as unknown as Terminal;
|
||||
|
||||
const connVisiable = ref<boolean>(false);
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
|
||||
const hostInfoRef = ref<FormInstance>();
|
||||
const hostTree = ref<Array<Host.HostTree>>();
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const defaultProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
const runRealTerminal = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
const hostfilterInfo = ref('');
|
||||
interface Tree {
|
||||
id: number;
|
||||
label: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
const rules = reactive({
|
||||
name: [Rules.requiredInput, Rules.name],
|
||||
addr: [Rules.requiredInput, Rules.ip],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
user: [Rules.requiredInput],
|
||||
authMode: [Rules.requiredSelect],
|
||||
password: [Rules.requiredInput],
|
||||
privateKey: [Rules.requiredInput],
|
||||
});
|
||||
|
||||
let hostInfo = reactive<Host.HostOperate>({
|
||||
id: 0,
|
||||
name: '',
|
||||
groupBelong: '',
|
||||
addr: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
authMode: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const ctx = getCurrentInstance() as any;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.toggle();
|
||||
}
|
||||
}
|
||||
const handleTabsRemove = (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
const onWSReceive = (message: any) => {
|
||||
if (!isJson(message.data)) {
|
||||
return;
|
||||
}
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${targetName}`] && ctx.refs[`Ref${targetName}`][0].onClose();
|
||||
}
|
||||
const tabs = terminalTabs.value;
|
||||
let activeName = terminalValue.value;
|
||||
if (activeName === targetName) {
|
||||
tabs.forEach((tab: any, index: any) => {
|
||||
if (tab.key === targetName) {
|
||||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||||
if (nextTab) {
|
||||
activeName = nextTab.key;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
terminalValue.value = activeName;
|
||||
terminalTabs.value = tabs.filter((tab: any) => tab.key !== targetName);
|
||||
const data = JSON.parse(message.data);
|
||||
term.element && term.focus();
|
||||
term.write(data.Data);
|
||||
};
|
||||
|
||||
const loadHost = async () => {
|
||||
const res = await getHostTree({});
|
||||
hostTree.value = res.data;
|
||||
for (let i = 0; i < hostTree.value.length; i++) {
|
||||
if (!hostTree.value[i].children) {
|
||||
hostTree.value.splice(i, 1);
|
||||
} else if (hostTree.value[i].children.length === 0) {
|
||||
hostTree.value.splice(i, 1);
|
||||
function isJson(str: string) {
|
||||
try {
|
||||
if (typeof JSON.parse(str) === 'object') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
watch(hostfilterInfo, (val: any) => {
|
||||
treeRef.value!.filter(val);
|
||||
});
|
||||
const filterHost = (value: string, data: any) => {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
};
|
||||
const loadCommand = async () => {
|
||||
const res = await getCommandList();
|
||||
commandList.value = res.data;
|
||||
};
|
||||
|
||||
function quickInput(val: any) {
|
||||
if (val !== '') {
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(val + '\n');
|
||||
quickCmd.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function batchInput() {
|
||||
if (batchVal.value === '' || !ctx) {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`Ref${tab.key}`] && ctx.refs[`Ref${tab.key}`][0].onSendMsg(batchVal.value + '\n');
|
||||
}
|
||||
batchVal.value = '';
|
||||
return;
|
||||
}
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(batchVal.value + '\n');
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
function beforeLeave(activeName: string) {
|
||||
if (activeName === 'newTabs') {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onNewTab = () => {
|
||||
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}`;
|
||||
const errorRealTerminal = (ex: any) => {
|
||||
let message = ex.message;
|
||||
if (!message) message = 'disconnected';
|
||||
term.write(`\x1b[31m${message}\x1b[m\r\n`);
|
||||
console.log('err');
|
||||
};
|
||||
|
||||
const onNewSsh = () => {
|
||||
connVisiable.value = true;
|
||||
if (hostInfoRef.value) {
|
||||
hostInfoRef.value.resetFields();
|
||||
const closeRealTerminal = (ev: CloseEvent) => {
|
||||
term.write(ev.reason);
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
let ifm = document.getElementById('terminal' + props.terminalID) as HTMLInputElement | null;
|
||||
let href = window.location.href;
|
||||
let ipLocal = href.split('//')[1].split(':')[0];
|
||||
term = new Terminal({
|
||||
lineHeight: 1.2,
|
||||
fontSize: 12,
|
||||
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: '#000000',
|
||||
},
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
scrollback: 100,
|
||||
tabStopWidth: 4,
|
||||
});
|
||||
if (ifm) {
|
||||
term.open(ifm);
|
||||
terminalSocket = new WebSocket(
|
||||
`ws://${ipLocal}:9999/api/v1/terminals?id=${props.wsID}&cols=${term.cols}&rows=${term.rows}`,
|
||||
);
|
||||
terminalSocket.onopen = runRealTerminal;
|
||||
terminalSocket.onmessage = onWSReceive;
|
||||
terminalSocket.onclose = closeRealTerminal;
|
||||
terminalSocket.onerror = errorRealTerminal;
|
||||
term.onData((data: any) => {
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
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 onConn = (node: Node, data: Tree) => {
|
||||
if (node.level === 1) {
|
||||
return;
|
||||
}
|
||||
let addr = data.label.split('@')[1].split(':')[0];
|
||||
terminalTabs.value.push({
|
||||
key: `${addr}-${++tabIndex}`,
|
||||
title: addr,
|
||||
wsID: data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${addr}-${tabIndex}`;
|
||||
const fitTerm = () => {
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
|
||||
if (!formEl) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
hostInfo.groupBelong = 'default';
|
||||
switch (ops) {
|
||||
case 'testConn':
|
||||
await testConn(hostInfo);
|
||||
ElMessage.success(i18n.global.t('terminal.connTestOk'));
|
||||
break;
|
||||
case 'saveAndConn':
|
||||
const res = await addHost(hostInfo);
|
||||
terminalTabs.value.push({
|
||||
key: `${res.data.addr}-${++tabIndex}`,
|
||||
title: res.data.addr,
|
||||
wsID: res.data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${res.data.addr}-${tabIndex}`;
|
||||
connVisiable.value = false;
|
||||
loadHost();
|
||||
}
|
||||
});
|
||||
const isWsOpen = () => {
|
||||
const readyState = terminalSocket && terminalSocket.readyState;
|
||||
return readyState === 1;
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
function onClose() {
|
||||
window.removeEventListener('resize', changeTerminalSize);
|
||||
terminalSocket && terminalSocket.close();
|
||||
term && term.dispose();
|
||||
}
|
||||
|
||||
function syncTerminal() {
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`Ref${terminal.key}`][0]) {
|
||||
terminal.status = ctx.refs[`Ref${terminal.key}`][0].isWsOpen() ? 'online' : 'closed';
|
||||
}
|
||||
function onSendMsg(command: string) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(command),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function changeTerminalSize() {
|
||||
fitTerm();
|
||||
const { cols, rows } = term;
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onConnLocal();
|
||||
loadHost();
|
||||
loadCommand();
|
||||
timer = setInterval(() => {
|
||||
syncTerminal();
|
||||
}, 1000 * 8);
|
||||
defineExpose({
|
||||
onClose,
|
||||
isWsOpen,
|
||||
onSendMsg,
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearInterval(Number(timer));
|
||||
timer = null;
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initTerm();
|
||||
window.addEventListener('resize', changeTerminalSize);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onClose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.terminal-tabs {
|
||||
:deep .el-tabs__header {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
::deep .el-tabs__nav {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
transition: transform var(--el-transition-duration);
|
||||
float: left;
|
||||
z-index: calc(var(--el-index-normal) + 1);
|
||||
}
|
||||
:deep .el-tabs__item {
|
||||
color: #575758;
|
||||
padding: 0 0px;
|
||||
}
|
||||
:deep .el-tabs__item.is-active {
|
||||
color: #ebeef5;
|
||||
background-color: #575758;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-tabs > .el-tabs__content {
|
||||
padding: 32px;
|
||||
color: #6b778c;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fullScreen {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 86px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.el-tabs--top.el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
|
||||
padding-right: 0px;
|
||||
#terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,178 +0,0 @@
|
||||
<template>
|
||||
<div :id="'terminal' + props.terminalID"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { Terminal } from 'xterm';
|
||||
import { AttachAddon } from 'xterm-addon-attach';
|
||||
import { Base64 } from 'js-base64';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
interface WsProps {
|
||||
terminalID: string;
|
||||
wsID: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<WsProps>(), {
|
||||
terminalID: '',
|
||||
wsID: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const loading = ref(true);
|
||||
let terminalSocket = ref(null) as unknown as WebSocket;
|
||||
let term = ref(null) as unknown as Terminal;
|
||||
|
||||
const runRealTerminal = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const onWSReceive = (message: any) => {
|
||||
if (!isJson(message.data)) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(message.data);
|
||||
term.element && term.focus();
|
||||
term.write(data.Data);
|
||||
};
|
||||
|
||||
function isJson(str: string) {
|
||||
try {
|
||||
if (typeof JSON.parse(str) === 'object') {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const errorRealTerminal = (ex: any) => {
|
||||
let message = ex.message;
|
||||
if (!message) message = 'disconnected';
|
||||
term.write(`\x1b[31m${message}\x1b[m\r\n`);
|
||||
console.log('err');
|
||||
};
|
||||
|
||||
const closeRealTerminal = (ev: CloseEvent) => {
|
||||
term.write(ev.reason);
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
let ifm = document.getElementById('terminal' + props.terminalID) as HTMLInputElement | null;
|
||||
let href = window.location.href;
|
||||
let ipLocal = href.split('//')[1].split(':')[0];
|
||||
term = new Terminal({
|
||||
lineHeight: 1.2,
|
||||
fontSize: 12,
|
||||
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: '#000000',
|
||||
},
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
scrollback: 100,
|
||||
tabStopWidth: 4,
|
||||
});
|
||||
if (ifm) {
|
||||
term.open(ifm);
|
||||
if (props.wsID === 0) {
|
||||
terminalSocket = new WebSocket(
|
||||
`ws://${ipLocal}:9999/api/v1/terminals/local?cols=${term.cols}&rows=${term.rows}`,
|
||||
);
|
||||
} else {
|
||||
terminalSocket = new WebSocket(
|
||||
`ws://${ipLocal}:9999/api/v1/terminals?id=${props.wsID}&cols=${term.cols}&rows=${term.rows}`,
|
||||
);
|
||||
}
|
||||
terminalSocket.onopen = runRealTerminal;
|
||||
terminalSocket.onmessage = onWSReceive;
|
||||
terminalSocket.onclose = closeRealTerminal;
|
||||
terminalSocket.onerror = errorRealTerminal;
|
||||
term.onData((data: any) => {
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
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 readyState = terminalSocket && terminalSocket.readyState;
|
||||
return readyState === 1;
|
||||
};
|
||||
|
||||
function onClose() {
|
||||
window.removeEventListener('resize', changeTerminalSize);
|
||||
terminalSocket && terminalSocket.close();
|
||||
term && term.dispose();
|
||||
}
|
||||
|
||||
function onSendMsg(command: string) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(command),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function changeTerminalSize() {
|
||||
fitTerm();
|
||||
const { cols, rows } = term;
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onClose,
|
||||
isWsOpen,
|
||||
onSendMsg,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initTerm();
|
||||
window.addEventListener('resize', changeTerminalSize);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onClose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
#terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
2
go.mod
2
go.mod
@ -25,7 +25,6 @@ require (
|
||||
github.com/gwatts/gin-adapter v1.0.0
|
||||
github.com/jinzhu/copier v0.3.5
|
||||
github.com/joho/godotenv v1.4.0
|
||||
github.com/kr/pty v1.1.8
|
||||
github.com/mholt/archiver/v4 v4.0.0-alpha.7
|
||||
github.com/minio/minio-go/v7 v7.0.36
|
||||
github.com/mojocn/base64Captcha v1.3.5
|
||||
@ -70,7 +69,6 @@ require (
|
||||
github.com/containerd/cgroups v1.0.3 // indirect
|
||||
github.com/containerd/containerd v1.6.8 // indirect
|
||||
github.com/containerd/continuity v0.3.0 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
||||
github.com/distribution/distribution/v3 v3.0.0-20220725133111-4bf3547399eb // indirect
|
||||
github.com/docker/distribution v2.8.1+incompatible // indirect
|
||||
|
5
go.sum
5
go.sum
@ -266,7 +266,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
|
||||
github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
|
||||
@ -645,8 +644,6 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
|
||||
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
@ -684,8 +681,6 @@ github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lL
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY=
|
||||
|
Loading…
Reference in New Issue
Block a user