1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-01-19 08:19:15 +08:00

feat: 支持直接从命令创建容器,表单样式优化 (#6741)

This commit is contained in:
ssongliu 2024-10-16 22:19:28 +08:00 committed by GitHub
parent 14bbb8c65d
commit 9c5da23a38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 451 additions and 205 deletions

View File

@ -257,6 +257,27 @@ func (b *BaseApi) ContainerCreate(c *gin.Context) {
helper.SuccessWithData(c, nil)
}
// @Tags Container
// @Summary Create container by command
// @Description 命令创建容器
// @Accept json
// @Param request body dto.ContainerCreateByCommand true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /containers/command [post]
func (b *BaseApi) ContainerCreateByCommand(c *gin.Context) {
var req dto.ContainerCreateByCommand
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.ContainerCreateByCommand(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Container
// @Summary Upgrade container
// @Description 更新容器镜像

View File

@ -90,6 +90,11 @@ type ContainerOperate struct {
RestartPolicy string `json:"restartPolicy"`
}
type ContainerCreateByCommand struct {
TaskID string `json:"taskID"`
Command string `json:"command"`
}
type ContainerUpgrade struct {
Name string `json:"name" validate:"required"`
Image string `json:"image" validate:"required"`

View File

@ -11,6 +11,7 @@ import (
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strconv"
@ -63,6 +64,7 @@ type IContainerService interface {
CreateCompose(req dto.ComposeCreate) error
ComposeOperation(req dto.ComposeOperation) error
ContainerCreate(req dto.ContainerOperate) error
ContainerCreateByCommand(req dto.ContainerCreateByCommand) error
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
@ -313,6 +315,25 @@ func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error
return datas, nil
}
func (u *ContainerService) ContainerCreateByCommand(req dto.ContainerCreateByCommand) error {
if cmd.CheckIllegal(req.Command) {
return buserr.New(constant.ErrCmdIllegal)
}
taskItem, err := task.NewTaskWithOps("-", task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", "-"), func(t *task.Task) error {
logPath := path.Join(constant.LogDir, task.TaskScopeContainer, req.TaskID+".log")
return cmd.ExecShell(logPath, 5*time.Minute, "bash", "-c", req.Command)
}, nil)
_ = taskItem.Execute()
}()
return nil
}
func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
client, err := docker.NewDockerClient()
if err != nil {

View File

@ -15,6 +15,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.GET("/stats/:id", baseApi.ContainerStats)
baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("command", baseApi.ContainerCreateByCommand)
baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
baRouter.POST("/info", baseApi.ContainerInfo)

View File

@ -18,6 +18,9 @@ export const loadResourceLimit = () => {
export const createContainer = (params: Container.ContainerHelper) => {
return http.post(`/containers`, params, TimeoutEnum.T_10M);
};
export const createContainerByCommand = (command: string, taskID: string) => {
return http.post(`/containers/command`, { command: command, taskID: taskID });
};
export const updateContainer = (params: Container.ContainerHelper) => {
return http.post(`/containers/update`, params, TimeoutEnum.T_10M);
};

View File

@ -37,6 +37,10 @@ const props = defineProps({
type: String,
default: '',
},
height: {
type: Number,
default: 0,
},
heightDiff: {
type: Number,
default: 200,
@ -45,6 +49,10 @@ const props = defineProps({
type: Number,
default: 400,
},
lineWrapping: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
@ -62,7 +70,7 @@ const initCodeMirror = () => {
const defaultTheme = EditorView.theme({
'&.cm-editor': {
minHeight: props.minHeight + 'px',
height: 'calc(100vh - ' + props.heightDiff + 'px)',
height: props.height ? props.height + 'px' : 'calc(100vh - ' + props.heightDiff + 'px)',
},
});
@ -78,6 +86,9 @@ const initCodeMirror = () => {
placeholder(props.placeholder),
EditorView.editable.of(!props.disabled),
];
if (props.lineWrapping) {
extensions.push(EditorView.lineWrapping);
}
switch (props.mode) {
case 'dockerfile':
extensions.push(StreamLanguage.define(dockerFile));

View File

@ -580,6 +580,10 @@ const message = {
},
container: {
create: 'Create',
createByCommand: 'Create by command',
commandInput: 'Command input',
commandRule: 'Please enter the correct docker run container creation command!',
commandHelper: 'This command will be executed on the server to create the container. Do you want to continue?',
edit: 'Edit container',
updateContainerHelper:
'Container editing requires rebuilding the container. Any data that has not been persisted will be lost. Do you want to continue?',
@ -669,6 +673,7 @@ const message = {
imageLoadErr: 'No image name detected for the container',
appHelper: 'This container is sourced from the app store; upgrading might render the service unavailable',
resource: 'Resource',
input: 'Input',
forcePull: 'forced image pull ',
forcePullHelper: 'Ignore existing images on the server and pull again.',

View File

@ -562,6 +562,10 @@ const message = {
},
container: {
create: '創建容器',
createByCommand: '命令創建',
commandInput: '命令輸入',
commandRule: '請輸入正確的 docker run 容器創建命令',
commandHelper: '將在伺服器上執行該條命令以創建容器是否繼續',
edit: '編輯容器',
updateContainerHelper: '容器編輯需要重建容器任何未持久化的數據將會丟失是否繼續',
containerList: '容器列表',
@ -643,6 +647,7 @@ const message = {
imageLoadErr: '未檢測到容器的鏡像名稱',
appHelper: '該容器來源於應用商店升級可能導致該服務不可用',
resource: '資源',
input: '手動輸入',
forcePull: '強製拉取鏡像',
forcePullHelper: '忽略服務器已存在的鏡像重新拉取一次',

View File

@ -562,6 +562,10 @@ const message = {
},
container: {
create: '创建容器',
createByCommand: '命令创建',
commandInput: '命令输入',
commandRule: '请输入正确的 docker run 容器创建命令',
commandHelper: '将在服务器上执行该条命令以创建容器是否继续',
edit: '编辑容器',
updateContainerHelper: '容器编辑需要重建容器任何未持久化的数据将会丢失是否继续',
containerList: '容器列表',
@ -644,6 +648,7 @@ const message = {
imageLoadErr: '未检测到容器的镜像名称',
appHelper: '该容器来源于应用商店升级可能导致该服务不可用',
resource: '资源',
input: '手动输入',
forcePull: '强制拉取镜像',
forcePullHelper: '忽略服务器已存在的镜像重新拉取一次',

View File

@ -0,0 +1,110 @@
<template>
<div>
<el-dialog v-model="drawerVisible" :title="$t('container.createByCommand')" :back="handleClose" width="70%">
<el-form
@submit.prevent
ref="formRef"
:rules="rules"
:model="form"
label-position="top"
v-loading="loading"
>
<el-form-item prop="command">
<CodemirrorPro
:lineWrapping="true"
v-model="form.command"
:height="300"
:minHeight="50"
mode="shell"
placeholder="e.g. docker run -p 80:80 --name my-nginx nginx"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="drawerVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="onSubmit(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
<TaskLog ref="taskLogRef" width="70%" />
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { ElForm } from 'element-plus';
import i18n from '@/lang';
import TaskLog from '@/components/task-log/index.vue';
import { createContainerByCommand } from '@/api/modules/container';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/util';
const drawerVisible = ref<boolean>(false);
const emit = defineEmits<{ (e: 'search'): void }>();
const loading = ref(false);
const form = reactive({
command: '',
});
const taskLogRef = ref();
const acceptParams = (): void => {
form.command = '';
drawerVisible.value = true;
};
const formRef = ref<FormInstance>();
type FormInstance = InstanceType<typeof ElForm>;
const verifyCommand = (rule: any, value: any, callback: any) => {
if (!form.command || !form.command.startsWith('docker run')) {
callback(new Error(i18n.global.t('container.commandRule')));
return;
}
callback();
};
const rules = reactive({
command: [{ validator: verifyCommand, trigger: 'blur', required: true }],
});
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
ElMessageBox.confirm(i18n.global.t('container.commandHelper'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
loading.value = true;
let taskID = newUUID();
await createContainerByCommand(form.command, taskID)
.then(() => {
loading.value = false;
emit('search');
openTaskLog(taskID);
drawerVisible.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
});
});
};
const openTaskLog = (taskID: string) => {
taskLogRef.value.openWithTaskID(taskID);
};
const handleClose = async () => {
drawerVisible.value = false;
emit('search');
};
defineExpose({
acceptParams,
});
</script>

View File

@ -1,6 +1,9 @@
<template>
<div>
<LayoutContent :title="isCreate ? $t('container.create') : $t('commons.button.edit') + ' - ' + form.name">
<LayoutContent
back-name="Container"
:title="isCreate ? $t('container.create') : $t('commons.button.edit') + ' - ' + form.name"
>
<template #prompt>
<el-alert
v-if="!isCreate && isFromApp(form)"
@ -18,13 +21,20 @@
:rules="rules"
label-width="80px"
>
<el-row>
<el-col :span="1"><br /></el-col>
<el-col :xs="24" :sm="20" :md="15" :lg="12" :xl="12">
<el-row type="flex" justify="center" :gutter="20">
<el-col :span="20">
<el-card>
<el-button v-if="isCreate" type="primary" icon="EditPen" plain @click="openDialog()">
{{ $t('container.commandInput') }}
</el-button>
<el-form-item class="mt-5" :label="$t('commons.table.name')" prop="name">
<el-input :disabled="isFromApp(form)" clearable v-model.trim="form.name" />
<div v-if="!isCreate && isFromApp(form)">
<span class="input-help">
<el-input
:disabled="isFromApp(form)"
class="mini-form-item"
clearable
v-model.trim="form.name"
/>
<span class="input-help" v-if="!isCreate && isFromApp(form)">
{{ $t('container.containerFromAppHelper1') }}
<el-button
style="margin-left: -5px"
@ -37,11 +47,17 @@
{{ $t('firewall.quickJump') }}
</el-button>
</span>
</div>
</el-form-item>
<el-form-item :label="$t('container.image')" prop="image">
<el-checkbox v-model="form.imageInput" :label="$t('container.input')" />
<el-select v-if="!form.imageInput" filterable v-model="form.image">
</el-form-item>
<el-form-item>
<el-select
class="mini-form-item"
v-if="!form.imageInput"
filterable
v-model="form.image"
>
<el-option
v-for="(item, index) of images"
:key="index"
@ -49,7 +65,7 @@
:label="item.option"
/>
</el-select>
<el-input v-else v-model="form.image" />
<el-input class="mini-form-item" v-else v-model="form.image" />
</el-form-item>
<el-form-item prop="forcePull">
<el-checkbox v-model="form.forcePull">
@ -57,6 +73,12 @@
</el-checkbox>
<span class="input-help">{{ $t('container.forcePullHelper') }}</span>
</el-form-item>
<el-form-item prop="autoRemove">
<el-checkbox v-model="form.autoRemove">
{{ $t('container.autoRemove') }}
</el-checkbox>
</el-form-item>
<el-form-item :label="$t('commons.table.port')">
<el-radio-group v-model="form.publishAllPorts" class="ml-4">
<el-radio :value="false">{{ $t('container.exposePort') }}</el-radio>
@ -64,9 +86,8 @@
</el-radio-group>
</el-form-item>
<el-form-item v-if="!form.publishAllPorts">
<el-card class="widthClass">
<el-table v-if="form.exposedPorts.length !== 0" :data="form.exposedPorts">
<el-table-column :label="$t('container.server')" min-width="150">
<el-table-column :label="$t('container.server')" min-width="200">
<template #default="{ row }">
<el-input
:placeholder="$t('container.serverExample')"
@ -74,7 +95,7 @@
/>
</template>
</el-table-column>
<el-table-column :label="$t('container.container')" min-width="80">
<el-table-column :label="$t('container.container')" min-width="120">
<template #default="{ row }">
<el-input
:placeholder="$t('container.containerExample')"
@ -82,19 +103,15 @@
/>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.protocol')" min-width="50">
<el-table-column :label="$t('commons.table.protocol')" min-width="100">
<template #default="{ row }">
<el-select
v-model="row.protocol"
style="width: 100%"
:placeholder="$t('container.serverExample')"
>
<el-option label="tcp" value="tcp" />
<el-option label="udp" value="udp" />
</el-select>
<el-radio-group v-model="row.protocol">
<el-radio value="tcp">tcp</el-radio>
<el-radio value="udp">udp</el-radio>
</el-radio-group>
</template>
</el-table-column>
<el-table-column min-width="35">
<el-table-column min-width="80">
<template #default="scope">
<el-button link type="primary" @click="handlePortsDelete(scope.$index)">
{{ $t('commons.button.delete') }}
@ -103,13 +120,16 @@
</el-table-column>
</el-table>
<el-button class="ml-3 mt-2" @click="handlePortsAdd()">
<el-button class="ml-3" @click="handlePortsAdd()">
{{ $t('commons.button.add') }}
</el-button>
</el-card>
</el-form-item>
</el-card>
<el-tabs type="border-card" class="mt-5">
<el-tab-pane :label="$t('container.network')">
<el-form-item :label="$t('container.network')" prop="network">
<el-select v-model="form.network">
<el-select class="mini-form-item" v-model="form.network">
<el-option
v-for="(item, indexV) of networks"
:key="indexV"
@ -118,17 +138,27 @@
/>
</el-select>
</el-form-item>
<el-form-item label="ipv4" prop="ipv4">
<el-input v-model="form.ipv4" :placeholder="$t('container.inputIpv4')" />
<el-input
class="mini-form-item"
v-model="form.ipv4"
:placeholder="$t('container.inputIpv4')"
/>
</el-form-item>
<el-form-item label="ipv6" prop="ipv6">
<el-input v-model="form.ipv6" :placeholder="$t('container.inputIpv6')" />
<el-input
class="mini-form-item"
v-model="form.ipv6"
:placeholder="$t('container.inputIpv6')"
/>
</el-form-item>
</el-tab-pane>
<el-form-item :label="$t('container.mount')">
<div v-for="(row, index) in form.volumes" :key="index" style="width: 100%">
<el-card class="mt-1">
<el-tab-pane :label="$t('container.mount')">
<el-form-item>
<el-table v-if="form.volumes.length !== 0" :data="form.volumes">
<el-table-column :label="$t('container.server')" min-width="120">
<template #default="{ row }">
<el-radio-group v-model="row.type">
<el-radio-button value="volume">
{{ $t('container.volumeOption') }}
@ -137,21 +167,18 @@
{{ $t('container.hostOption') }}
</el-radio-button>
</el-radio-group>
<el-button
class="float-right mt-3"
link
type="primary"
@click="handleVolumesDelete(index)"
</template>
</el-table-column>
<el-table-column
:label="$t('container.volumeOption') + '/' + $t('container.hostOption')"
min-width="200"
>
{{ $t('commons.button.delete') }}
</el-button>
<el-row class="mt-4" :gutter="5">
<el-col :span="10">
<el-form-item
<template #default="{ row }">
<el-select
v-if="row.type === 'volume'"
:label="$t('container.volumeOption')"
filterable
v-model="row.sourceDir"
>
<el-select filterable v-model="row.sourceDir">
<div v-for="(item, indexV) of volumes" :key="indexV">
<el-tooltip
:hide-after="20"
@ -165,31 +192,41 @@
</el-tooltip>
</div>
</el-select>
</el-form-item>
<el-form-item v-else :label="$t('container.hostOption')">
<el-input v-model="row.sourceDir" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item :label="$t('container.mode')">
<el-select class="widthClass" filterable v-model="row.mode">
<el-option value="rw" :label="$t('container.modeRW')" />
<el-option value="ro" :label="$t('container.modeR')" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="9">
<el-form-item :label="$t('container.containerDir')">
<el-input v-else v-model="row.sourceDir" />
</template>
</el-table-column>
<el-table-column :label="$t('container.mode')" min-width="120">
<template #default="{ row }">
<el-radio-group v-model="row.mode">
<el-radio value="rw">{{ $t('container.modeRW') }}</el-radio>
<el-radio value="ro">{{ $t('container.modeR') }}</el-radio>
</el-radio-group>
</template>
</el-table-column>
<el-table-column :label="$t('container.containerDir')" min-width="200">
<template #default="{ row }">
<el-input v-model="row.containerDir" />
</el-form-item>
</el-col>
</el-row>
</el-card>
</div>
</template>
</el-table-column>
<el-table-column min-width="80">
<template #default="scope">
<el-button
link
type="primary"
@click="handleVolumesDelete(scope.$index)"
>
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-button @click="handleVolumesAdd()">
{{ $t('commons.button.add') }}
</el-button>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="Command">
<el-form-item label="Command" prop="cmdStr">
<el-input v-model="form.cmdStr" :placeholder="$t('container.cmdHelper')" />
</el-form-item>
@ -199,31 +236,15 @@
:placeholder="$t('container.entrypointHelper')"
/>
</el-form-item>
<el-form-item prop="autoRemove">
<el-checkbox v-model="form.autoRemove">
{{ $t('container.autoRemove') }}
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.privileged">
{{ $t('container.privileged') }}
</el-checkbox>
<span class="input-help">{{ $t('container.privilegedHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('container.console')">
<el-checkbox v-model="form.tty">{{ $t('container.tty') }}</el-checkbox>
<el-checkbox v-model="form.openStdin">
{{ $t('container.openStdin') }}
</el-checkbox>
</el-form-item>
<el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy">
<el-radio-group v-model="form.restartPolicy">
<el-radio value="no">{{ $t('container.no') }}</el-radio>
<el-radio value="always">{{ $t('container.always') }}</el-radio>
<el-radio value="on-failure">{{ $t('container.onFailure') }}</el-radio>
<el-radio value="unless-stopped">{{ $t('container.unlessStopped') }}</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('container.resource')">
<el-form-item :label="$t('container.cpuShare')" prop="cpuShares">
<el-input class="mini-form-item" v-model.number="form.cpuShares" />
<span class="input-help">{{ $t('container.cpuShareHelper') }}</span>
@ -239,7 +260,8 @@
</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
{{ $t('container.limitHelper', [limits.cpu])
}}{{ $t('commons.units.core') }}
</span>
</el-form-item>
<el-form-item
@ -250,8 +272,19 @@
<el-input class="mini-form-item" v-model="form.memory">
<template #append><div style="width: 35px">MB</div></template>
</el-input>
<span class="input-help">{{ $t('container.limitHelper', [limits.memory]) }}MB</span>
<span class="input-help">
{{ $t('container.limitHelper', [limits.memory]) }}MB
</span>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.privileged">
{{ $t('container.privileged') }}
</el-checkbox>
<span class="input-help">{{ $t('container.privilegedHelper') }}</span>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('container.tag') + ' & ' + $t('container.env')">
<el-form-item :label="$t('container.tag')" prop="labelsStr">
<el-input
type="textarea"
@ -268,18 +301,36 @@
v-model="form.envStr"
/>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('container.restartPolicy')">
<el-form-item prop="restartPolicy">
<el-radio-group v-model="form.restartPolicy">
<el-radio value="no">{{ $t('container.no') }}</el-radio>
<el-radio value="always">{{ $t('container.always') }}</el-radio>
<el-radio value="on-failure">{{ $t('container.onFailure') }}</el-radio>
<el-radio value="unless-stopped">
{{ $t('container.unlessStopped') }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item class="mt-5">
<el-button :disabled="loading" @click="goBack">
{{ $t('commons.button.back') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="onSubmit(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
</LayoutContent>
<Command ref="commandRef" />
</div>
</template>
@ -288,6 +339,7 @@ import { reactive, ref } from 'vue';
import { Rules, checkFloatNumberRange, checkNumberRange } from '@/global/form-rules';
import i18n from '@/lang';
import { ElForm, ElMessageBox } from 'element-plus';
import Command from '@/views/container/container/command/index.vue';
import {
listImage,
listVolume,
@ -389,6 +441,7 @@ const search = async () => {
loadNetworkOptions();
};
const commandRef = ref();
const images = ref();
const volumes = ref();
const networks = ref();
@ -412,6 +465,10 @@ const goBack = () => {
router.push({ name: 'Container' });
};
const openDialog = () => {
commandRef.value.acceptParams();
};
const handlePortsAdd = () => {
let item = {
host: '',

View File

@ -19,10 +19,12 @@
</el-form-item>
<el-form-item>
<div class="w-full">
<el-checkbox v-model="form.deleteTag">
{{ $t('container.imageTagDeleteHelper') }}
</el-checkbox>
<el-checkbox-group class="ml-5" v-if="form.deleteTag" v-model="form.deleteTags">
</div>
<el-checkbox-group v-if="form.deleteTag" v-model="form.deleteTags">
<el-checkbox v-for="item in tags" :key="item" :value="item" :label="item" />
</el-checkbox-group>
</el-form-item>