feat: 容器镜像支持多选删除 (#6748)

Refs #6562
This commit is contained in:
ssongliu 2024-10-17 15:27:31 +08:00 committed by GitHub
parent 9c5da23a38
commit f50c473cf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 139 additions and 55 deletions

View file

@ -150,12 +150,13 @@ func (b *BaseApi) ImageRemove(c *gin.Context) {
return return
} }
if err := imageService.ImageRemove(req); err != nil { data, err := imageService.ImageRemove(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return return
} }
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, data)
} }
// @Tags Container Image // @Tags Container Image

View file

@ -319,13 +319,30 @@ func (u *ContainerService) ContainerCreateByCommand(req dto.ContainerCreateByCom
if cmd.CheckIllegal(req.Command) { if cmd.CheckIllegal(req.Command) {
return buserr.New(constant.ErrCmdIllegal) return buserr.New(constant.ErrCmdIllegal)
} }
taskItem, err := task.NewTaskWithOps("-", task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1) if !strings.HasPrefix(strings.TrimSpace(req.Command), "docker run ") {
return errors.New("error command format")
}
containerName := ""
commands := strings.Split(req.Command, " ")
for index, val := range commands {
if val == "--name" && len(commands) > index+1 {
containerName = commands[index+1]
}
}
if !strings.Contains(req.Command, " -d ") {
req.Command = strings.ReplaceAll(req.Command, "docker run", "docker run -d")
}
if len(containerName) == 0 {
containerName = fmt.Sprintf("1Panel-%s-%s", common.RandStr(5), common.RandStrAndNum(4))
req.Command += fmt.Sprintf(" --name %s", containerName)
}
taskItem, err := task.NewTaskWithOps(containerName, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil { if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
go func() { go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", "-"), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", containerName), func(t *task.Task) error {
logPath := path.Join(constant.LogDir, task.TaskScopeContainer, req.TaskID+".log") logPath := path.Join(constant.LogDir, task.TaskScopeContainer, req.TaskID+".log")
return cmd.ExecShell(logPath, 5*time.Minute, "bash", "-c", req.Command) return cmd.ExecShell(logPath, 5*time.Minute, "bash", "-c", req.Command)
}, nil) }, nil)

View file

@ -40,7 +40,7 @@ type IImageService interface {
ImageLoad(req dto.ImageLoad) error ImageLoad(req dto.ImageLoad) error
ImageSave(req dto.ImageSave) error ImageSave(req dto.ImageSave) error
ImagePush(req dto.ImagePush) error ImagePush(req dto.ImagePush) error
ImageRemove(req dto.BatchDelete) error ImageRemove(req dto.BatchDelete) (dto.ContainerPruneReport, error)
ImageTag(req dto.ImageTag) error ImageTag(req dto.ImageTag) error
} }
@ -399,24 +399,31 @@ func (u *ImageService) ImagePush(req dto.ImagePush) error {
return nil return nil
} }
func (u *ImageService) ImageRemove(req dto.BatchDelete) error { func (u *ImageService) ImageRemove(req dto.BatchDelete) (dto.ContainerPruneReport, error) {
report := dto.ContainerPruneReport{}
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return report, err
} }
defer client.Close() defer client.Close()
for _, id := range req.Names { for _, id := range req.Names {
imageItem, _, err := client.ImageInspectWithRaw(context.TODO(), id)
if err != nil {
return report, err
}
if _, err := client.ImageRemove(context.TODO(), id, image.RemoveOptions{Force: req.Force, PruneChildren: true}); err != nil { if _, err := client.ImageRemove(context.TODO(), id, image.RemoveOptions{Force: req.Force, PruneChildren: true}); err != nil {
if strings.Contains(err.Error(), "image is being used") || strings.Contains(err.Error(), "is using") { if strings.Contains(err.Error(), "image is being used") || strings.Contains(err.Error(), "is using") {
if strings.Contains(id, "sha256:") { if strings.Contains(id, "sha256:") {
return buserr.New(constant.ErrObjectInUsed) return report, buserr.New(constant.ErrObjectInUsed)
} }
return buserr.WithDetail(constant.ErrInUsed, id, nil) return report, buserr.WithDetail(constant.ErrInUsed, id, nil)
} }
return err return report, err
} }
report.DeletedNumber++
report.SpaceReclaimed += int(imageItem.Size)
} }
return nil return report, nil
} }
func formatFileSize(fileSize int64) (size string) { func formatFileSize(fileSize int64) (size string) {

View file

@ -94,7 +94,7 @@ export const imageTag = (params: Container.ImageTag) => {
return http.post(`/containers/image/tag`, params); return http.post(`/containers/image/tag`, params);
}; };
export const imageRemove = (params: Container.BatchDelete) => { export const imageRemove = (params: Container.BatchDelete) => {
return http.post(`/containers/image/remove`, params); return http.post<Container.ContainerPruneReport>(`/containers/image/remove`, params);
}; };
// network // network

View file

@ -1,67 +1,63 @@
<template> <template>
<el-dialog v-model="dialogVisible" :destroy-on-close="true" :close-on-click-modal="false" width="30%"> <DrawerPro v-model="dialogVisible" :header="$t('container.imagePrune')" :back="handleClose" size="small">
<template #header>
<div class="card-header">
<span>{{ $t('container.imagePrune') }}</span>
</div>
</template>
<el-form ref="deleteForm" v-loading="loading"> <el-form ref="deleteForm" v-loading="loading">
<el-form-item> <el-form-item>
<el-radio-group v-model="withTagAll"> <el-radio-group class="w-full" v-model="scope" @change="changeScope">
<el-radio :value="false">{{ $t('container.imagePruneSome') }}</el-radio> <el-radio value="untag">{{ $t('container.imagePruneSome') }}</el-radio>
<el-radio :value="true">{{ $t('container.imagePruneAll') }}</el-radio> <el-radio value="unused">{{ $t('container.imagePruneAll') }}</el-radio>
</el-radio-group> </el-radio-group>
<span class="input-help">{{ showMsg }}</span>
<el-checkbox
class="w-full"
v-if="data.length !== 0"
v-model="checkAll"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
{{ $t('commons.table.all') }}
</el-checkbox>
<el-checkbox-group v-model="checkedLists" @change="handleCheckedChange">
<el-checkbox class="w-full" v-for="(item, index) in data" :key="index" :value="item.id">
{{
item.tags && item.tags[0]
? item.tags[0]
: item.id.replaceAll('sha256:', '').substring(0, 12)
}}
</el-checkbox>
</el-checkbox-group>
</el-form-item> </el-form-item>
<span v-if="withTagAll">
{{ unUsedList.length !== 0 ? $t('container.imagePruneAllHelper') : $t('container.imagePruneAllEmpty') }}
</span>
<span v-else>
{{
unTagList.length !== 0 ? $t('container.imagePruneSomeHelper') : $t('container.imagePruneSomeEmpty')
}}
</span>
<div v-if="!withTagAll">
<ul v-for="(item, index) in unTagList" :key="index">
<li v-if="item.tags && item.tags[0]">
{{ item.tags[0] }}
</li>
<li v-else>
{{ item.id.replaceAll('sha256:', '').substring(0, 12) }}
</li>
</ul>
</div>
<div v-else>
<ul v-for="(item, index) in unUsedList" :key="index">
<li v-if="item.tags && item.tags[0]">{{ item.tags.join(', ') }}</li>
<li v-else>{{ item.id.replaceAll('sha256:', '').substring(0, 12) }}</li>
</ul>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="dialogVisible = false"> <el-button @click="dialogVisible = false">
{{ $t('commons.button.cancel') }} {{ $t('commons.button.cancel') }}
</el-button> </el-button>
<el-button type="primary" :disabled="buttonDisable() || loading" @click="onClean"> <el-button type="primary" :disabled="data.length === 0 || loading" @click="onClean">
{{ $t('commons.button.confirm') }} {{ $t('commons.button.confirm') }}
</el-button> </el-button>
</span> </span>
</template> </template>
</el-dialog> </DrawerPro>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { containerPrune, listAllImage } from '@/api/modules/container'; import { containerPrune, imageRemove, listAllImage } from '@/api/modules/container';
import i18n from '@/lang'; import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message'; import { MsgSuccess } from '@/utils/message';
import { computeSize } from '@/utils/util'; import { computeSize } from '@/utils/util';
import { ref } from 'vue'; import { ref } from 'vue';
const dialogVisible = ref(false); const dialogVisible = ref(false);
const withTagAll = ref(false); const scope = ref('untag');
const showMsg = ref();
const loading = ref(); const loading = ref();
const unTagList = ref(); const unTagList = ref([]);
const unUsedList = ref(); const unUsedList = ref([]);
const data = ref([]);
const checkAll = ref(false);
const isIndeterminate = ref(false);
const checkedLists = ref([]);
const acceptParams = async (): Promise<void> => { const acceptParams = async (): Promise<void> => {
const res = await listAllImage(); const res = await listAllImage();
@ -81,20 +77,62 @@ const acceptParams = async (): Promise<void> => {
} }
} }
dialogVisible.value = true; dialogVisible.value = true;
withTagAll.value = false; scope.value = 'untag';
changeScope();
}; };
const emit = defineEmits<{ (e: 'search'): void }>(); const emit = defineEmits<{ (e: 'search'): void }>();
const buttonDisable = () => { const changeScope = () => {
return withTagAll.value ? unUsedList.value.length === 0 : unTagList.value.length === 0; if (scope.value === 'untag') {
data.value = unTagList.value || [];
showMsg.value =
data.value.length === 0
? i18n.global.t('container.imagePruneSomeHelper')
: i18n.global.t('container.imagePruneSomeEmpty');
return;
}
data.value = unUsedList.value || [];
showMsg.value =
data.value.length === 0
? i18n.global.t('container.imagePruneAllHelper')
: i18n.global.t('container.imagePruneAllEmpty');
return;
};
const handleCheckAllChange = (val: boolean) => {
checkedLists.value = [];
if (!val) {
isIndeterminate.value = false;
return;
}
for (const item of data.value) {
checkedLists.value.push(item.id);
}
};
const handleCheckedChange = (value: string[]) => {
const checkedCount = value.length;
checkAll.value = checkedCount === unUsedList.value.length;
isIndeterminate.value = checkedCount > 0 && checkedCount < unUsedList.value.length;
};
const handleClose = () => {
dialogVisible.value = false;
}; };
const onClean = async () => { const onClean = async () => {
loading.value = true; loading.value = true;
if (checkAll.value) {
prune();
return;
}
removeImage();
};
const prune = async () => {
let params = { let params = {
pruneType: 'image', pruneType: 'image',
withTagAll: withTagAll.value, withTagAll: scope.value === 'unused',
}; };
await containerPrune(params) await containerPrune(params)
.then((res) => { .then((res) => {
@ -113,6 +151,27 @@ const onClean = async () => {
}); });
}; };
const removeImage = async () => {
let params = {
names: checkedLists.value,
};
await imageRemove(params)
.then((res) => {
loading.value = false;
dialogVisible.value = false;
MsgSuccess(
i18n.global.t('container.cleanSuccessWithSpace', [
res.data.deletedNumber,
computeSize(res.data.spaceReclaimed),
]),
);
emit('search');
})
.catch(() => {
loading.value = false;
});
};
defineExpose({ defineExpose({
acceptParams, acceptParams,
}); });