fix: 终端本地连接方式修改

This commit is contained in:
ssongliu 2022-12-19 21:32:27 +08:00 committed by ssongliu
parent 0fea645692
commit bdecd777a7
16 changed files with 635 additions and 885 deletions

View file

@ -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)

View file

@ -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"`

View file

@ -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
}

View file

@ -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
}

View file

@ -17,6 +17,5 @@ func (s *TerminalRouter) InitTerminalRouter(Router *gin.RouterGroup) {
baseApi := v1.ApiGroupApp.BaseApi
{
terminalRouter.GET("", baseApi.WsSsh)
terminalRouter.GET("/local", baseApi.LocalWsSsh)
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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';
},
},
];

View file

@ -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>

View file

@ -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>

View file

@ -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>&nbsp;{{ item.title }}&nbsp;&nbsp;</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;

View file

@ -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>&nbsp;{{ item.title }}&nbsp;&nbsp;</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>

View file

@ -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
View file

@ -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
View file

@ -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=