Browse Source

feat: 增加容器编辑功能 (#1381)

pull/1385/head
ssongliu 1 year ago committed by GitHub
parent
commit
352978b54d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 56
      backend/app/api/v1/container.go
  2. 2
      backend/app/dto/container.go
  3. 175
      backend/app/service/container.go
  4. 2
      backend/router/ro_container.go
  5. 86
      cmd/server/docs/docs.go
  6. 86
      cmd/server/docs/swagger.json
  7. 56
      cmd/server/docs/swagger.yaml
  8. 7
      frontend/src/api/interface/container.ts
  9. 8
      frontend/src/api/modules/container.ts
  10. 6
      frontend/src/lang/modules/en.ts
  11. 3
      frontend/src/lang/modules/zh.ts
  12. 53
      frontend/src/views/container/container/index.vue
  13. 229
      frontend/src/views/container/container/operate/index.vue

56
backend/app/api/v1/container.go

@ -153,17 +153,69 @@ func (b *BaseApi) OperatorCompose(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags Container
// @Summary Update container
// @Description 更新容器
// @Accept json
// @Param request body dto.ContainerOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /containers/update [post]
// @x-panel-log {"bodyKeys":["name","image"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"更新容器 [name][image]","formatEN":"update container [name][image]"}
func (b *BaseApi) ContainerUpdate(c *gin.Context) {
var req dto.ContainerOperate
if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
if err := global.VALID.Struct(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
if err := containerService.ContainerUpdate(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Container
// @Summary Load container info
// @Description 获取容器表单信息
// @Accept json
// @Param request body dto.OperationWithName true "request"
// @Success 200 {object} dto.ContainerOperate
// @Security ApiKeyAuth
// @Router /containers/info [post]
func (b *BaseApi) ContainerInfo(c *gin.Context) {
var req dto.OperationWithName
if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
if err := global.VALID.Struct(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
data, err := containerService.ContainerInfo(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Container // @Tags Container
// @Summary Create container // @Summary Create container
// @Description 创建容器 // @Description 创建容器
// @Accept json // @Accept json
// @Param request body dto.ContainerCreate true "request" // @Param request body dto.ContainerOperate true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Router /containers [post] // @Router /containers [post]
// @x-panel-log {"bodyKeys":["name","image"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"创建容器 [name][image]","formatEN":"create container [name][image]"} // @x-panel-log {"bodyKeys":["name","image"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"创建容器 [name][image]","formatEN":"create container [name][image]"}
func (b *BaseApi) ContainerCreate(c *gin.Context) { func (b *BaseApi) ContainerCreate(c *gin.Context) {
var req dto.ContainerCreate var req dto.ContainerOperate
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err) helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return return

2
backend/app/dto/container.go

@ -30,7 +30,7 @@ type ContainerInfo struct {
IsFromCompose bool `json:"isFromCompose"` IsFromCompose bool `json:"isFromCompose"`
} }
type ContainerCreate struct { type ContainerOperate struct {
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
PublishAllPorts bool `json:"publishAllPorts"` PublishAllPorts bool `json:"publishAllPorts"`

175
backend/app/service/container.go

@ -39,7 +39,9 @@ type IContainerService interface {
PageCompose(req dto.SearchWithPage) (int64, interface{}, error) PageCompose(req dto.SearchWithPage) (int64, interface{}, error)
CreateCompose(req dto.ComposeCreate) (string, error) CreateCompose(req dto.ComposeCreate) (string, error)
ComposeOperation(req dto.ComposeOperation) error ComposeOperation(req dto.ComposeOperation) error
ContainerCreate(req dto.ContainerCreate) error ContainerCreate(req dto.ContainerOperate) error
ContainerUpdate(req dto.ContainerOperate) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerLogClean(req dto.OperationWithName) error ContainerLogClean(req dto.OperationWithName) error
ContainerOperation(req dto.ContainerOperation) error ContainerOperation(req dto.ContainerOperation) error
ContainerLogs(wsConn *websocket.Conn, container, since, tail string, follow bool) error ContainerLogs(wsConn *websocket.Conn, container, since, tail string, follow bool) error
@ -213,75 +215,125 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) (dto.ContainerPruneRepo
return report, nil return report, nil
} }
func (u *ContainerService) ContainerCreate(req dto.ContainerCreate) error { func (u *ContainerService) ContainerCreate(req dto.ContainerOperate) error {
portMap, err := checkPortStats(req.ExposedPorts) client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
client, err := docker.NewDockerClient()
if err != nil { var config *container.Config
var hostConf *container.HostConfig
if err := loadConfigInfo(req, config, hostConf); err != nil {
return err return err
} }
exposeds := make(nat.PortSet) global.LOG.Infof("new container info %s has been made, now start to create", req.Name)
for port := range portMap {
exposeds[port] = struct{}{} ctx := context.Background()
if !checkImageExist(client, req.Image) {
if err := pullImages(ctx, client, req.Image); err != nil {
return err
}
} }
config := &container.Config{ container, err := client.ContainerCreate(ctx, config, hostConf, &network.NetworkingConfig{}, &v1.Platform{}, req.Name)
Image: req.Image, if err != nil {
Cmd: req.Cmd, _ = client.ContainerRemove(ctx, req.Name, types.ContainerRemoveOptions{RemoveVolumes: true, Force: true})
Env: req.Env, return err
Labels: stringsToMap(req.Labels),
Tty: true,
OpenStdin: true,
ExposedPorts: exposeds,
} }
hostConf := &container.HostConfig{ global.LOG.Infof("create container %s successful! now check if the container is started and delete the container information if it is not.", req.Name)
AutoRemove: req.AutoRemove, if err := client.ContainerStart(ctx, container.ID, types.ContainerStartOptions{}); err != nil {
PublishAllPorts: req.PublishAllPorts, _ = client.ContainerRemove(ctx, req.Name, types.ContainerRemoveOptions{RemoveVolumes: true, Force: true})
RestartPolicy: container.RestartPolicy{Name: req.RestartPolicy}, return fmt.Errorf("create successful but start failed, err: %v", err)
} }
if req.RestartPolicy == "on-failure" { return nil
hostConf.RestartPolicy.MaximumRetryCount = 5 }
func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
} }
if req.NanoCPUs != 0 { ctx := context.Background()
hostConf.NanoCPUs = req.NanoCPUs * 1000000000 oldContainer, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
return nil, err
} }
if req.CPUShares != 0 {
hostConf.CPUShares = req.CPUShares var data dto.ContainerOperate
data.Name = strings.ReplaceAll(oldContainer.Name, "/", "")
data.Image = oldContainer.Config.Image
data.Cmd = oldContainer.Config.Cmd
data.Env = oldContainer.Config.Env
for key, val := range oldContainer.Config.Labels {
data.Labels = append(data.Labels, fmt.Sprintf("%s=%s", key, val))
} }
if req.Memory != 0 { for key, val := range oldContainer.HostConfig.PortBindings {
hostConf.Memory = req.Memory var itemPort dto.PortHelper
if !strings.Contains(string(key), "/") {
continue
}
itemPort.ContainerPort = strings.Split(string(key), "/")[0]
itemPort.Protocol = strings.Split(string(key), "/")[1]
for _, binds := range val {
itemPort.HostIP = binds.HostIP
itemPort.HostPort = binds.HostPort
data.ExposedPorts = append(data.ExposedPorts, itemPort)
}
} }
if len(req.ExposedPorts) != 0 { data.AutoRemove = oldContainer.HostConfig.AutoRemove
hostConf.PortBindings = portMap data.PublishAllPorts = oldContainer.HostConfig.PublishAllPorts
data.RestartPolicy = oldContainer.HostConfig.RestartPolicy.Name
if oldContainer.HostConfig.NanoCPUs != 0 {
data.NanoCPUs = oldContainer.HostConfig.NanoCPUs / 1000000000
} }
if len(req.Volumes) != 0 { if oldContainer.HostConfig.Memory != 0 {
config.Volumes = make(map[string]struct{}) data.Memory = oldContainer.HostConfig.Memory
for _, volume := range req.Volumes { }
config.Volumes[volume.ContainerDir] = struct{}{} for _, bind := range oldContainer.HostConfig.Binds {
hostConf.Binds = append(hostConf.Binds, fmt.Sprintf("%s:%s:%s", volume.SourceDir, volume.ContainerDir, volume.Mode)) parts := strings.Split(bind, ":")
if len(parts) != 3 {
continue
} }
data.Volumes = append(data.Volumes, dto.VolumeHelper{SourceDir: parts[0], ContainerDir: parts[1], Mode: parts[2]})
} }
global.LOG.Infof("new container info %s has been made, now start to create", req.Name) return &data, nil
}
func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
ctx := context.Background() ctx := context.Background()
oldContainer, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
return err
}
if !checkImageExist(client, req.Image) { if !checkImageExist(client, req.Image) {
if err := pullImages(ctx, client, req.Image); err != nil { if err := pullImages(ctx, client, req.Image); err != nil {
return err return err
} }
} }
config := oldContainer.Config
hostConf := oldContainer.HostConfig
if err := loadConfigInfo(req, config, hostConf); err != nil {
return err
}
if err := client.ContainerRemove(ctx, req.Name, types.ContainerRemoveOptions{Force: true}); err != nil {
return err
}
global.LOG.Infof("new container info %s has been update, now start to recreate", req.Name)
container, err := client.ContainerCreate(ctx, config, hostConf, &network.NetworkingConfig{}, &v1.Platform{}, req.Name) container, err := client.ContainerCreate(ctx, config, hostConf, &network.NetworkingConfig{}, &v1.Platform{}, req.Name)
if err != nil { if err != nil {
_ = client.ContainerRemove(ctx, req.Name, types.ContainerRemoveOptions{RemoveVolumes: true, Force: true}) return fmt.Errorf("recreate contianer failed, err: %v", err)
return err
} }
global.LOG.Infof("create container %s successful! now check if the container is started and delete the container information if it is not.", req.Name) global.LOG.Infof("update container %s successful! now check if the container is started.", req.Name)
if err := client.ContainerStart(ctx, container.ID, types.ContainerStartOptions{}); err != nil { if err := client.ContainerStart(ctx, container.ID, types.ContainerStartOptions{}); err != nil {
_ = client.ContainerRemove(ctx, req.Name, types.ContainerRemoveOptions{RemoveVolumes: true, Force: true}) return fmt.Errorf("update successful but start failed, err: %v", err)
return fmt.Errorf("create successful but start failed, err: %v", err)
} }
return nil return nil
} }
@ -559,3 +611,44 @@ func checkPortStats(ports []dto.PortHelper) (nat.PortMap, error) {
} }
return portMap, nil return portMap, nil
} }
func loadConfigInfo(req dto.ContainerOperate, config *container.Config, hostConf *container.HostConfig) error {
portMap, err := checkPortStats(req.ExposedPorts)
if err != nil {
return err
}
exposeds := make(nat.PortSet)
for port := range portMap {
exposeds[port] = struct{}{}
}
config.Image = req.Image
config.Cmd = req.Cmd
config.Env = req.Env
config.Labels = stringsToMap(req.Labels)
config.ExposedPorts = exposeds
hostConf.AutoRemove = req.AutoRemove
hostConf.PublishAllPorts = req.PublishAllPorts
hostConf.RestartPolicy = container.RestartPolicy{Name: req.RestartPolicy}
if req.RestartPolicy == "on-failure" {
hostConf.RestartPolicy.MaximumRetryCount = 5
}
if req.NanoCPUs != 0 {
hostConf.NanoCPUs = req.NanoCPUs * 1000000000
}
if req.Memory != 0 {
hostConf.Memory = req.Memory
}
if len(req.ExposedPorts) != 0 {
hostConf.PortBindings = portMap
}
hostConf.Binds = []string{}
if len(req.Volumes) != 0 {
config.Volumes = make(map[string]struct{})
for _, volume := range req.Volumes {
config.Volumes[volume.ContainerDir] = struct{}{}
hostConf.Binds = append(hostConf.Binds, fmt.Sprintf("%s:%s:%s", volume.SourceDir, volume.ContainerDir, volume.Mode))
}
}
return nil
}

2
backend/router/ro_container.go

@ -19,6 +19,8 @@ func (s *ContainerRouter) InitContainerRouter(Router *gin.RouterGroup) {
baRouter.GET("/stats/:id", baseApi.ContainerStats) baRouter.GET("/stats/:id", baseApi.ContainerStats)
baRouter.POST("", baseApi.ContainerCreate) baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer) baRouter.POST("/search", baseApi.SearchContainer)
baRouter.GET("/search/log", baseApi.ContainerLogs) baRouter.GET("/search/log", baseApi.ContainerLogs)
baRouter.POST("/clean/log", baseApi.CleanContainerLog) baRouter.POST("/clean/log", baseApi.CleanContainerLog)

86
cmd/server/docs/docs.go

@ -916,7 +916,7 @@ var doc = `{
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/dto.ContainerCreate" "$ref": "#/definitions/dto.ContainerOperate"
} }
} }
], ],
@ -1781,6 +1781,42 @@ var doc = `{
} }
} }
}, },
"/containers/info": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取容器表单信息",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Load container info",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.OperationWithName"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerOperate"
}
}
}
}
},
"/containers/inspect": { "/containers/inspect": {
"post": { "post": {
"security": [ "security": [
@ -2597,6 +2633,49 @@ var doc = `{
} }
} }
}, },
"/containers/update": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "更新容器",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Update container",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerOperate"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"x-panel-log": {
"BeforeFuntions": [],
"bodyKeys": [
"name",
"image"
],
"formatEN": "update container [name][image]",
"formatZH": "更新容器 [name][image]",
"paramKeys": []
}
}
},
"/containers/volume": { "/containers/volume": {
"post": { "post": {
"security": [ "security": [
@ -10584,7 +10663,7 @@ var doc = `{
} }
} }
}, },
"dto.ContainerCreate": { "dto.ContainerOperate": {
"type": "object", "type": "object",
"properties": { "properties": {
"autoRemove": { "autoRemove": {
@ -10596,6 +10675,9 @@ var doc = `{
"type": "string" "type": "string"
} }
}, },
"cpushares": {
"type": "integer"
},
"env": { "env": {
"type": "array", "type": "array",
"items": { "items": {

86
cmd/server/docs/swagger.json

@ -902,7 +902,7 @@
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/dto.ContainerCreate" "$ref": "#/definitions/dto.ContainerOperate"
} }
} }
], ],
@ -1767,6 +1767,42 @@
} }
} }
}, },
"/containers/info": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取容器表单信息",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Load container info",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.OperationWithName"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerOperate"
}
}
}
}
},
"/containers/inspect": { "/containers/inspect": {
"post": { "post": {
"security": [ "security": [
@ -2583,6 +2619,49 @@
} }
} }
}, },
"/containers/update": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "更新容器",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Update container",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerOperate"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"x-panel-log": {
"BeforeFuntions": [],
"bodyKeys": [
"name",
"image"
],
"formatEN": "update container [name][image]",
"formatZH": "更新容器 [name][image]",
"paramKeys": []
}
}
},
"/containers/volume": { "/containers/volume": {
"post": { "post": {
"security": [ "security": [
@ -10570,7 +10649,7 @@
} }
} }
}, },
"dto.ContainerCreate": { "dto.ContainerOperate": {
"type": "object", "type": "object",
"properties": { "properties": {
"autoRemove": { "autoRemove": {
@ -10582,6 +10661,9 @@
"type": "string" "type": "string"
} }
}, },
"cpushares": {
"type": "integer"
},
"env": { "env": {
"type": "array", "type": "array",
"items": { "items": {

56
cmd/server/docs/swagger.yaml

@ -253,7 +253,7 @@ definitions:
- name - name
- path - path
type: object type: object
dto.ContainerCreate: dto.ContainerOperate:
properties: properties:
autoRemove: autoRemove:
type: boolean type: boolean
@ -261,6 +261,8 @@ definitions:
items: items:
type: string type: string
type: array type: array
cpushares:
type: integer
env: env:
items: items:
type: string type: string
@ -3926,7 +3928,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/dto.ContainerCreate' $ref: '#/definitions/dto.ContainerOperate'
responses: responses:
"200": "200":
description: "" description: ""
@ -4483,6 +4485,28 @@ paths:
formatEN: tag image [reponame][targetName] formatEN: tag image [reponame][targetName]
formatZH: tag 镜像 [reponame][targetName] formatZH: tag 镜像 [reponame][targetName]
paramKeys: [] paramKeys: []
/containers/info:
post:
consumes:
- application/json
description: 获取容器表单信息
parameters:
- description: request
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.OperationWithName'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.ContainerOperate'
security:
- ApiKeyAuth: []
summary: Load container info
tags:
- Container
/containers/inspect: /containers/inspect:
post: post:
consumes: consumes:
@ -5000,6 +5024,34 @@ paths:
formatEN: update compose template information [name] formatEN: update compose template information [name]
formatZH: 更新 compose 模版 [name] formatZH: 更新 compose 模版 [name]
paramKeys: [] paramKeys: []
/containers/update:
post:
consumes:
- application/json
description: 更新容器
parameters:
- description: request
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.ContainerOperate'
responses:
"200":
description: ""
security:
- ApiKeyAuth: []
summary: Update container
tags:
- Container
x-panel-log:
BeforeFuntions: []
bodyKeys:
- name
- image
formatEN: update container [name][image]
formatZH: 更新容器 [name][image]
paramKeys: []
/containers/volume: /containers/volume:
post: post:
consumes: consumes:

7
frontend/src/api/interface/container.ts

@ -10,13 +10,18 @@ export namespace Container {
name: string; name: string;
filters: string; filters: string;
} }
export interface ContainerCreate { export interface ContainerHelper {
name: string; name: string;
image: string; image: string;
cmdStr: string;
memoryUnit: string;
memoryItem: number;
cmd: Array<string>; cmd: Array<string>;
publishAllPorts: boolean; publishAllPorts: boolean;
exposedPorts: Array<Port>; exposedPorts: Array<Port>;
nanoCPUs: number; nanoCPUs: number;
cpuShares: number;
cpuUnit: string;
memory: number; memory: number;
volumes: Array<Volume>; volumes: Array<Volume>;
autoRemove: boolean; autoRemove: boolean;

8
frontend/src/api/modules/container.ts

@ -5,9 +5,15 @@ import { Container } from '../interface/container';
export const searchContainer = (params: Container.ContainerSearch) => { export const searchContainer = (params: Container.ContainerSearch) => {
return http.post<ResPage<Container.ContainerInfo>>(`/containers/search`, params, 400000); return http.post<ResPage<Container.ContainerInfo>>(`/containers/search`, params, 400000);
}; };
export const createContainer = (params: Container.ContainerCreate) => { export const createContainer = (params: Container.ContainerHelper) => {
return http.post(`/containers`, params, 3000000); return http.post(`/containers`, params, 3000000);
}; };
export const updateContainer = (params: Container.ContainerHelper) => {
return http.post(`/containers/update`, params, 3000000);
};
export const loadContainerInfo = (name: string) => {
return http.post<Container.ContainerHelper>(`/containers/info`, { name: name });
};
export const cleanContainerLog = (containerName: string) => { export const cleanContainerLog = (containerName: string) => {
return http.post(`/containers/clean/log`, { name: containerName }); return http.post(`/containers/clean/log`, { name: containerName });
}; };

6
frontend/src/lang/modules/en.ts

@ -444,6 +444,8 @@ const message = {
}, },
container: { container: {
createContainer: 'Create container', createContainer: 'Create container',
updateContaienrHelper:
'Container editing requires rebuilding the container. Any data that has not been persisted will be lost. Do you want to continue?',
containerList: 'Container list', containerList: 'Container list',
operatorHelper: '{0} will be performed on the selected container. Do you want to continue?', operatorHelper: '{0} will be performed on the selected container. Do you want to continue?',
operatorAppHelper: operatorAppHelper:
@ -489,6 +491,7 @@ const message = {
user: 'User', user: 'User',
command: 'Command', command: 'Command',
commandHelper: 'Please enter the correct command, separated by spaces if there are multiple commands.',
custom: 'Custom', custom: 'Custom',
emptyUser: 'When empty, you will log in as default', emptyUser: 'When empty, you will log in as default',
containerTerminal: 'Terminal', containerTerminal: 'Terminal',
@ -514,9 +517,10 @@ const message = {
mode: 'Mode', mode: 'Mode',
env: 'Environment', env: 'Environment',
restartPolicy: 'Restart policy', restartPolicy: 'Restart policy',
always: 'always',
unlessStopped: 'unless-stopped', unlessStopped: 'unless-stopped',
onFailure: 'on-failurefive times by default', onFailure: 'on-failurefive times by default',
no: 'no', no: 'never',
image: 'Image', image: 'Image',
imagePull: 'Image pull', imagePull: 'Image pull',

3
frontend/src/lang/modules/zh.ts

@ -453,6 +453,7 @@ const message = {
}, },
container: { container: {
createContainer: '创建容器', createContainer: '创建容器',
updateContaienrHelper: '容器编辑需要重建容器任何未持久化的数据将会丢失是否继续',
containerList: '容器列表', containerList: '容器列表',
operatorHelper: '将对选中容器进行 {0} 操作是否继续', operatorHelper: '将对选中容器进行 {0} 操作是否继续',
operatorAppHelper: '存在来源于应用商店的容器{0} 操作可能会影响到该服务的正常使用是否确认', operatorAppHelper: '存在来源于应用商店的容器{0} 操作可能会影响到该服务的正常使用是否确认',
@ -495,6 +496,7 @@ const message = {
user: '用户', user: '用户',
command: '命令', command: '命令',
commandHelper: '请输入正确的命令多个命令空格分割',
custom: '自定义', custom: '自定义',
containerTerminal: '终端', containerTerminal: '终端',
emptyUser: '为空时将使用容器默认的用户登录', emptyUser: '为空时将使用容器默认的用户登录',
@ -520,6 +522,7 @@ const message = {
mode: '权限', mode: '权限',
env: '环境变量', env: '环境变量',
restartPolicy: '重启规则', restartPolicy: '重启规则',
always: '一直重启',
unlessStopped: '关闭后重启', unlessStopped: '关闭后重启',
onFailure: '失败后重启默认重启 5 ', onFailure: '失败后重启默认重启 5 ',
no: '不重启', no: '不重启',

53
frontend/src/views/container/container/index.vue

@ -9,7 +9,7 @@
<template #toolbar> <template #toolbar>
<el-row> <el-row>
<el-col :xs="24" :sm="16" :md="16" :lg="16" :xl="16"> <el-col :xs="24" :sm="16" :md="16" :lg="16" :xl="16">
<el-button type="primary" @click="onCreate()"> <el-button type="primary" @click="onOpenDialog('create')">
{{ $t('container.createContainer') }} {{ $t('container.createContainer') }}
</el-button> </el-button>
<el-button type="primary" plain @click="onClean()"> <el-button type="primary" plain @click="onClean()">
@ -123,7 +123,7 @@
<ReNameDialog @search="search" ref="dialogReNameRef" /> <ReNameDialog @search="search" ref="dialogReNameRef" />
<ContainerLogDialog ref="dialogContainerLogRef" /> <ContainerLogDialog ref="dialogContainerLogRef" />
<CreateDialog @search="search" ref="dialogCreateRef" /> <CreateDialog @search="search" ref="dialogOperateRef" />
<MonitorDialog ref="dialogMonitorRef" /> <MonitorDialog ref="dialogMonitorRef" />
<TerminalDialog ref="dialogTerminalRef" /> <TerminalDialog ref="dialogTerminalRef" />
</div> </div>
@ -133,14 +133,21 @@
import Tooltip from '@/components/tooltip/index.vue'; import Tooltip from '@/components/tooltip/index.vue';
import TableSetting from '@/components/table-setting/index.vue'; import TableSetting from '@/components/table-setting/index.vue';
import ReNameDialog from '@/views/container/container/rename/index.vue'; import ReNameDialog from '@/views/container/container/rename/index.vue';
import CreateDialog from '@/views/container/container/create/index.vue'; import CreateDialog from '@/views/container/container/operate/index.vue';
import MonitorDialog from '@/views/container/container/monitor/index.vue'; import MonitorDialog from '@/views/container/container/monitor/index.vue';
import ContainerLogDialog from '@/views/container/container/log/index.vue'; import ContainerLogDialog from '@/views/container/container/log/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue'; import TerminalDialog from '@/views/container/container/terminal/index.vue';
import CodemirrorDialog from '@/components/codemirror-dialog/index.vue'; import CodemirrorDialog from '@/components/codemirror-dialog/index.vue';
import Status from '@/components/status/index.vue'; import Status from '@/components/status/index.vue';
import { reactive, onMounted, ref } from 'vue'; import { reactive, onMounted, ref } from 'vue';
import { ContainerOperator, containerPrune, inspect, loadDockerStatus, searchContainer } from '@/api/modules/container'; import {
ContainerOperator,
containerPrune,
inspect,
loadContainerInfo,
loadDockerStatus,
searchContainer,
} from '@/api/modules/container';
import { Container } from '@/api/interface/container'; import { Container } from '@/api/interface/container';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import i18n from '@/lang'; import i18n from '@/lang';
@ -211,9 +218,35 @@ const search = async () => {
}); });
}; };
const dialogCreateRef = ref(); const dialogOperateRef = ref();
const onCreate = () => { const onEdit = async (container: string) => {
dialogCreateRef.value!.acceptParams(); const res = await loadContainerInfo(container);
if (res.data) {
onOpenDialog('edit', res.data);
}
};
const onOpenDialog = async (
title: string,
rowData: Partial<Container.ContainerHelper> = {
cmd: [],
cmdStr: '',
exposedPorts: [],
nanoCPUs: 0,
memory: 0,
memoryItem: 0,
memoryUnit: 'MB',
cpuUnit: 'Core',
volumes: [],
labels: [],
env: [],
restartPolicy: 'no',
},
) => {
let params = {
title,
rowData: { ...rowData },
};
dialogOperateRef.value!.acceptParams(params);
}; };
const dialogMonitorRef = ref(); const dialogMonitorRef = ref();
@ -336,6 +369,12 @@ const onOperate = async (operation: string) => {
}; };
const buttons = [ const buttons = [
{
label: i18n.global.t('commons.button.edit'),
click: (row: Container.ContainerInfo) => {
onEdit(row.containerID);
},
},
{ {
label: i18n.global.t('file.terminal'), label: i18n.global.t('file.terminal'),
disabled: (row: Container.ContainerInfo) => { disabled: (row: Container.ContainerInfo) => {

229
frontend/src/views/container/container/create/index.vue → frontend/src/views/container/container/operate/index.vue

@ -3,14 +3,21 @@
<template #header> <template #header>
<DrawerHeader :header="$t('container.createContainer')" :back="handleClose" /> <DrawerHeader :header="$t('container.createContainer')" :back="handleClose" />
</template> </template>
<el-form ref="formRef" label-position="top" v-loading="loading" :model="form" :rules="rules" label-width="80px"> <el-form
ref="formRef"
label-position="top"
v-loading="loading"
:model="dialogData.rowData!"
:rules="rules"
label-width="80px"
>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-col :span="22"> <el-col :span="22">
<el-form-item :label="$t('container.name')" prop="name"> <el-form-item :label="$t('container.name')" prop="name">
<el-input clearable v-model.trim="form.name" /> <el-input clearable v-model.trim="dialogData.rowData!.name" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.image')" prop="image"> <el-form-item :label="$t('container.image')" prop="image">
<el-select class="widthClass" allow-create filterable v-model="form.image"> <el-select class="widthClass" allow-create filterable v-model="dialogData.rowData!.image">
<el-option <el-option
v-for="(item, index) of images" v-for="(item, index) of images"
:key="index" :key="index"
@ -20,15 +27,15 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.port')"> <el-form-item :label="$t('container.port')">
<el-radio-group v-model="form.publishAllPorts" class="ml-4"> <el-radio-group v-model="dialogData.rowData!.publishAllPorts" class="ml-4">
<el-radio :label="false">{{ $t('container.exposePort') }}</el-radio> <el-radio :label="false">{{ $t('container.exposePort') }}</el-radio>
<el-radio :label="true">{{ $t('container.exposeAll') }}</el-radio> <el-radio :label="true">{{ $t('container.exposeAll') }}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="!form.publishAllPorts"> <el-form-item v-if="!dialogData.rowData!.publishAllPorts">
<el-card class="widthClass"> <el-card class="widthClass">
<table style="width: 100%" class="tab-table"> <table style="width: 100%" class="tab-table">
<tr v-if="form.exposedPorts.length !== 0"> <tr v-if="dialogData.rowData!.exposedPorts.length !== 0">
<th scope="col" width="45%" align="left"> <th scope="col" width="45%" align="left">
<label>{{ $t('container.server') }}</label> <label>{{ $t('container.server') }}</label>
</th> </th>
@ -40,7 +47,7 @@
</th> </th>
<th align="left"></th> <th align="left"></th>
</tr> </tr>
<tr v-for="(row, index) in form.exposedPorts" :key="index"> <tr v-for="(row, index) in dialogData.rowData!.exposedPorts" :key="index">
<td width="45%"> <td width="45%">
<el-input <el-input
:placeholder="$t('container.serverExample')" :placeholder="$t('container.serverExample')"
@ -76,19 +83,21 @@
</el-card> </el-card>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.cmd')" prop="cmdStr"> <el-form-item :label="$t('container.cmd')" prop="cmdStr">
<el-input :placeholder="$t('container.cmdHelper')" v-model="form.cmdStr" /> <el-input :placeholder="$t('container.cmdHelper')" v-model="dialogData.rowData!.cmdStr" />
</el-form-item> </el-form-item>
<el-form-item prop="autoRemove"> <el-form-item prop="autoRemove">
<el-checkbox v-model="form.autoRemove">{{ $t('container.autoRemove') }}</el-checkbox> <el-checkbox v-model="dialogData.rowData!.autoRemove">
{{ $t('container.autoRemove') }}
</el-checkbox>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.cpuShare')" prop="cpuShares"> <el-form-item :label="$t('container.cpuShare')" prop="cpuShares">
<el-input style="width: 40%" v-model.number="form.cpuShares" /> <el-input style="width: 40%" v-model.number="dialogData.rowData!.cpuShares" />
<span class="input-help">{{ $t('container.cpuShareHelper') }}</span> <span class="input-help">{{ $t('container.cpuShareHelper') }}</span>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.cpuQuota')" prop="nanoCPUs"> <el-form-item :label="$t('container.cpuQuota')" prop="nanoCPUs">
<el-input type="number" style="width: 40%" v-model.number="form.nanoCPUs"> <el-input type="number" style="width: 40%" v-model.number="dialogData.rowData!.nanoCPUs">
<template #append> <template #append>
<el-select v-model="form.cpuUnit" disabled style="width: 85px"> <el-select v-model="dialogData.rowData!.cpuUnit" disabled style="width: 85px">
<el-option label="Core" value="Core" /> <el-option label="Core" value="Core" />
</el-select> </el-select>
</template> </template>
@ -96,9 +105,13 @@
<span class="input-help">{{ $t('container.limitHelper') }}</span> <span class="input-help">{{ $t('container.limitHelper') }}</span>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.memoryLimit')" prop="memoryItem"> <el-form-item :label="$t('container.memoryLimit')" prop="memoryItem">
<el-input style="width: 40%" v-model.number="form.memoryItem"> <el-input style="width: 40%" v-model.number="dialogData.rowData!.memoryItem">
<template #append> <template #append>
<el-select v-model="form.memoryUnit" placeholder="Select" style="width: 85px"> <el-select
v-model="dialogData.rowData!.memoryUnit"
placeholder="Select"
style="width: 85px"
>
<el-option label="KB" value="KB" /> <el-option label="KB" value="KB" />
<el-option label="MB" value="MB" /> <el-option label="MB" value="MB" />
<el-option label="GB" value="GB" /> <el-option label="GB" value="GB" />
@ -110,7 +123,7 @@
<el-form-item :label="$t('container.mount')"> <el-form-item :label="$t('container.mount')">
<el-card style="width: 100%"> <el-card style="width: 100%">
<table style="width: 100%" class="tab-table"> <table style="width: 100%" class="tab-table">
<tr v-if="form.volumes.length !== 0"> <tr v-if="dialogData.rowData!.volumes.length !== 0">
<th scope="col" width="39%" align="left"> <th scope="col" width="39%" align="left">
<label>{{ $t('container.serverPath') }}</label> <label>{{ $t('container.serverPath') }}</label>
</th> </th>
@ -122,7 +135,7 @@
</th> </th>
<th align="left"></th> <th align="left"></th>
</tr> </tr>
<tr v-for="(row, index) in form.volumes" :key="index"> <tr v-for="(row, index) in dialogData.rowData!.volumes" :key="index">
<td width="39%"> <td width="39%">
<el-select <el-select
class="widthClass" class="widthClass"
@ -169,23 +182,24 @@
<el-input <el-input
type="textarea" type="textarea"
:placeholder="$t('container.tagHelper')" :placeholder="$t('container.tagHelper')"
:autosize="{ minRows: 2, maxRows: 4 }" :autosize="{ minRows: 2, maxRows: 10 }"
v-model="form.labelsStr" v-model="dialogData.rowData!.labelsStr"
/> />
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.env')" prop="envStr"> <el-form-item :label="$t('container.env')" prop="envStr">
<el-input <el-input
type="textarea" type="textarea"
:placeholder="$t('container.tagHelper')" :placeholder="$t('container.tagHelper')"
:autosize="{ minRows: 2, maxRows: 4 }" :autosize="{ minRows: 2, maxRows: 10 }"
v-model="form.envStr" v-model="dialogData.rowData!.envStr"
/> />
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy"> <el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy">
<el-radio-group v-model="form.restartPolicy"> <el-radio-group v-model="dialogData.rowData!.restartPolicy">
<el-radio label="unless-stopped">{{ $t('container.unlessStopped') }}</el-radio>
<el-radio label="on-failure">{{ $t('container.onFailure') }}</el-radio>
<el-radio label="no">{{ $t('container.no') }}</el-radio> <el-radio label="no">{{ $t('container.no') }}</el-radio>
<el-radio label="always">{{ $t('container.always') }}</el-radio>
<el-radio label="on-failure">{{ $t('container.onFailure') }}</el-radio>
<el-radio label="unless-stopped">{{ $t('container.unlessStopped') }}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -210,73 +224,56 @@ import { Rules, checkNumberRange } from '@/global/form-rules';
import i18n from '@/lang'; import i18n from '@/lang';
import { ElForm } from 'element-plus'; import { ElForm } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/index.vue'; import DrawerHeader from '@/components/drawer-header/index.vue';
import { listImage, listVolume, createContainer } from '@/api/modules/container'; import { listImage, listVolume, createContainer, updateContainer } from '@/api/modules/container';
import { Container } from '@/api/interface/container'; import { Container } from '@/api/interface/container';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
import { checkIp, checkPort } from '@/utils/util'; import { checkIp, checkPort, computeSize } from '@/utils/util';
const loading = ref(false); const loading = ref(false);
interface DialogProps {
title: string;
rowData?: Container.ContainerHelper;
getTableList?: () => Promise<any>;
}
const title = ref<string>('');
const drawerVisiable = ref(false); const drawerVisiable = ref(false);
const form = reactive({
name: '',
image: '',
cmdStr: '',
cmd: [] as Array<string>,
publishAllPorts: false,
exposedPorts: [] as Array<Container.Port>,
cpuShares: 1024,
nanoCPUs: 0,
memory: 0,
memoryItem: 0,
memoryUnit: 'MB',
cpuUnit: 'Core',
volumes: [] as Array<Container.Volume>,
autoRemove: false,
labels: [] as Array<string>,
labelsStr: '',
env: [] as Array<string>,
envStr: '',
restartPolicy: '',
});
const images = ref();
const volumes = ref();
const acceptParams = (): void => { const dialogData = ref<DialogProps>({
handlReset(); title: '',
drawerVisiable.value = true; });
const acceptParams = (params: DialogProps): void => {
dialogData.value = params;
title.value = i18n.global.t('commons.button.' + dialogData.value.title);
if (params.title === 'edit') {
dialogData.value.rowData.cpuUnit = 'Core';
let itemMem = computeSize(Number(dialogData.value.rowData.memory));
dialogData.value.rowData.memoryItem = itemMem.indexOf(' ') !== -1 ? Number(itemMem.split(' ')[0]) : 0;
dialogData.value.rowData.memoryUnit = itemMem.indexOf(' ') !== -1 ? itemMem.split(' ')[1] : 'MB';
let itemCmd = '';
for (const item of dialogData.value.rowData.cmd) {
itemCmd += `'${item}' `;
}
dialogData.value.rowData.cmdStr = itemCmd ? itemCmd.substring(0, itemCmd.length - 1) : '';
dialogData.value.rowData.labelsStr = dialogData.value.rowData.labels.join('\n');
dialogData.value.rowData.envStr = dialogData.value.rowData.env.join('\n');
for (const item of dialogData.value.rowData.exposedPorts) {
item.host = item.hostPort;
}
}
loadImageOptions(); loadImageOptions();
loadVolumeOptions(); loadVolumeOptions();
drawerVisiable.value = true;
}; };
const emit = defineEmits<{ (e: 'search'): void }>();
const handlReset = () => { const images = ref();
form.name = ''; const volumes = ref();
form.image = '';
form.cmdStr = '';
form.cmd = [];
form.publishAllPorts = false;
form.exposedPorts = [];
form.cpuShares = 1024;
form.nanoCPUs = 0;
form.memory = 0;
form.memoryItem = 0;
form.memoryUnit = 'MB';
form.cpuUnit = 'Core';
form.volumes = [];
form.autoRemove = false;
form.labels = [];
form.labelsStr = '';
form.env = [];
form.envStr = '';
form.restartPolicy = 'no';
};
const handleClose = () => { const handleClose = () => {
drawerVisiable.value = false; drawerVisiable.value = false;
}; };
const emit = defineEmits<{ (e: 'search'): void }>();
const rules = reactive({ const rules = reactive({
cpuShares: [Rules.number, checkNumberRange(2, 262144)], cpuShares: [Rules.number, checkNumberRange(2, 262144)],
name: [Rules.requiredInput, Rules.name], name: [Rules.requiredInput, Rules.name],
@ -296,10 +293,10 @@ const handlePortsAdd = () => {
hostPort: '', hostPort: '',
protocol: 'tcp', protocol: 'tcp',
}; };
form.exposedPorts.push(item); dialogData.value.rowData!.exposedPorts.push(item);
}; };
const handlePortsDelete = (index: number) => { const handlePortsDelete = (index: number) => {
form.exposedPorts.splice(index, 1); dialogData.value.rowData!.exposedPorts.splice(index, 1);
}; };
const handleVolumesAdd = () => { const handleVolumesAdd = () => {
@ -308,10 +305,10 @@ const handleVolumesAdd = () => {
containerDir: '', containerDir: '',
mode: 'rw', mode: 'rw',
}; };
form.volumes.push(item); dialogData.value.rowData!.volumes.push(item);
}; };
const handleVolumesDelete = (index: number) => { const handleVolumesDelete = (index: number) => {
form.volumes.splice(index, 1); dialogData.value.rowData!.volumes.splice(index, 1);
}; };
const loadImageOptions = async () => { const loadImageOptions = async () => {
@ -323,8 +320,8 @@ const loadVolumeOptions = async () => {
volumes.value = res.data; volumes.value = res.data;
}; };
const onSubmit = async (formEl: FormInstance | undefined) => { const onSubmit = async (formEl: FormInstance | undefined) => {
if (form.volumes.length !== 0) { if (dialogData.value.rowData!.volumes.length !== 0) {
for (const item of form.volumes) { for (const item of dialogData.value.rowData!.volumes) {
if (!item.containerDir || !item.sourceDir) { if (!item.containerDir || !item.sourceDir) {
MsgError(i18n.global.t('container.volumeHelper')); MsgError(i18n.global.t('container.volumeHelper'));
return; return;
@ -334,48 +331,78 @@ const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return; if (!formEl) return;
formEl.validate(async (valid) => { formEl.validate(async (valid) => {
if (!valid) return; if (!valid) return;
if (form.envStr.length !== 0) { if (dialogData.value.rowData!.envStr.length !== 0) {
form.env = form.envStr.split('\n'); dialogData.value.rowData!.env = dialogData.value.rowData!.envStr.split('\n');
} }
if (form.labelsStr.length !== 0) { if (dialogData.value.rowData!.labelsStr.length !== 0) {
form.labels = form.labelsStr.split('\n'); dialogData.value.rowData!.labels = dialogData.value.rowData!.labelsStr.split('\n');
} }
if (form.cmdStr.length !== 0) { if (dialogData.value.rowData!.cmdStr.length !== 0) {
form.cmd = form.cmdStr.split(' '); let itemCmd = dialogData.value.rowData!.cmdStr.split(' ');
for (const cmd of itemCmd) {
if (cmd.startsWith(`'`) && cmd.endsWith(`'`) && cmd.length >= 3) {
dialogData.value.rowData!.cmd.push(cmd.substring(1, cmd.length - 2));
} else {
MsgError(i18n.global.t('container.commandHelper'));
return;
}
}
} }
if (!checkPortValid()) { if (!checkPortValid()) {
return; return;
} }
switch (form.memoryUnit) { switch (dialogData.value.rowData!.memoryUnit) {
case 'KB': case 'KB':
form.memory = form.memoryItem * 1024; dialogData.value.rowData!.memory = dialogData.value.rowData!.memoryItem * 1024;
break; break;
case 'MB': case 'MB':
form.memory = form.memoryItem * 1024 * 1024; dialogData.value.rowData!.memory = dialogData.value.rowData!.memoryItem * 1024 * 1024;
break; break;
case 'GB': case 'GB':
form.memory = form.memoryItem * 1024 * 1024 * 1024; dialogData.value.rowData!.memory = dialogData.value.rowData!.memoryItem * 1024 * 1024 * 1024;
break; break;
} }
loading.value = true; loading.value = true;
await createContainer(form) if (dialogData.value.title === 'create') {
.then(() => { await createContainer(dialogData.value.rowData!)
loading.value = false; .then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); loading.value = false;
emit('search'); MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisiable.value = false; emit('search');
}) drawerVisiable.value = false;
.catch(() => { })
loading.value = false; .catch(() => {
loading.value = false;
});
} else {
ElMessageBox.confirm(
i18n.global.t('container.updateContaienrHelper'),
i18n.global.t('commons.button.edit'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
).then(async () => {
await updateContainer(dialogData.value.rowData!)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('search');
drawerVisiable.value = false;
})
.catch(() => {
loading.value = false;
});
}); });
}
}); });
}; };
const checkPortValid = () => { const checkPortValid = () => {
if (form.exposedPorts.length === 0) { if (dialogData.value.rowData!.exposedPorts.length === 0) {
return true; return true;
} }
for (const port of form.exposedPorts) { for (const port of dialogData.value.rowData!.exposedPorts) {
if (port.host.indexOf(':') !== -1) { if (port.host.indexOf(':') !== -1) {
port.hostIP = port.host.split(':')[0]; port.hostIP = port.host.split(':')[0];
if (checkIp(port.hostIP)) { if (checkIp(port.hostIP)) {
Loading…
Cancel
Save