feat: 增加容器、镜像、网络、存储卷清理功能 (#1117)

This commit is contained in:
ssongliu 2023-05-23 19:00:06 +08:00 committed by GitHub
parent 626782102a
commit 7596099aa1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 511 additions and 9 deletions

View File

@ -179,6 +179,33 @@ func (b *BaseApi) ContainerCreate(c *gin.Context) {
helper.SuccessWithData(c, nil)
}
// @Tags Container
// @Summary Clean container
// @Description 容器清理
// @Accept json
// @Param request body dto.ContainerPrune true "request"
// @Success 200 {object} dto.ContainerPruneReport
// @Security ApiKeyAuth
// @Router /containers/prune [post]
// @x-panel-log {"bodyKeys":["pruneType"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"清理容器 [pruneType]","formatEN":"clean container [pruneType]"}
func (b *BaseApi) ContainerPrune(c *gin.Context) {
var req dto.ContainerPrune
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
}
report, err := containerService.Prune(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, report)
}
// @Tags Container
// @Summary Clean container log
// @Description 清理容器日志

View File

@ -80,6 +80,16 @@ type ContainerOperation struct {
NewName string `json:"newName"`
}
type ContainerPrune struct {
PruneType string `json:"pruneType" validate:"required,oneof=container image volume network"`
WithTagAll bool `josn:"withTagAll"`
}
type ContainerPruneReport struct {
DeletedNumber int `json:"deletedNumber"`
SpaceReclaimed int `json:"spaceReclaimed"`
}
type Network struct {
ID string `json:"id"`
Name string `json:"name"`

View File

@ -51,6 +51,7 @@ type IContainerService interface {
CreateVolume(req dto.VolumeCreat) error
TestCompose(req dto.ComposeCreate) (bool, error)
ComposeUpdate(req dto.ComposeUpdate) error
Prune(req dto.ContainerPrune) (dto.ContainerPruneReport, error)
}
func NewIContainerService() IContainerService {
@ -167,6 +168,51 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
return string(bytes), nil
}
func (u *ContainerService) Prune(req dto.ContainerPrune) (dto.ContainerPruneReport, error) {
report := dto.ContainerPruneReport{}
client, err := docker.NewDockerClient()
if err != nil {
return report, err
}
pruneFilters := filters.NewArgs()
if req.WithTagAll {
pruneFilters.Add("dangling", "false")
if req.PruneType != "image" {
pruneFilters.Add("until", "24h")
}
}
switch req.PruneType {
case "container":
rep, err := client.ContainersPrune(context.Background(), pruneFilters)
if err != nil {
return report, err
}
report.DeletedNumber = len(rep.ContainersDeleted)
report.SpaceReclaimed = int(rep.SpaceReclaimed)
case "image":
rep, err := client.ImagesPrune(context.Background(), pruneFilters)
if err != nil {
return report, err
}
report.DeletedNumber = len(rep.ImagesDeleted)
report.SpaceReclaimed = int(rep.SpaceReclaimed)
case "network":
rep, err := client.NetworksPrune(context.Background(), pruneFilters)
if err != nil {
return report, err
}
report.DeletedNumber = len(rep.NetworksDeleted)
case "volume":
rep, err := client.VolumesPrune(context.Background(), pruneFilters)
if err != nil {
return report, err
}
report.DeletedNumber = len(rep.VolumesDeleted)
report.SpaceReclaimed = int(rep.SpaceReclaimed)
}
return report, nil
}
func (u *ContainerService) ContainerCreate(req dto.ContainerCreate) error {
portMap, err := checkPortStats(req.ExposedPorts)
if err != nil {

View File

@ -24,6 +24,7 @@ func (s *ContainerRouter) InitContainerRouter(Router *gin.RouterGroup) {
baRouter.POST("/clean/log", baseApi.CleanContainerLog)
baRouter.POST("/inspect", baseApi.Inspect)
baRouter.POST("/operate", baseApi.ContainerOperation)
baRouter.POST("/prune", baseApi.ContainerPrune)
baRouter.GET("/repo", baseApi.ListRepo)
baRouter.POST("/repo/status", baseApi.CheckRepoStatus)

View File

@ -1984,6 +1984,51 @@ var doc = `{
}
}
},
"/containers/prune": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "容器清理",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Clean container",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerPrune"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerPruneReport"
}
}
},
"x-panel-log": {
"BeforeFuntions": [],
"bodyKeys": [
"pruneType"
],
"formatEN": "clean container [pruneType]",
"formatZH": "清理容器 [pruneType]",
"paramKeys": []
}
}
},
"/containers/repo": {
"get": {
"security": [
@ -10523,6 +10568,37 @@ var doc = `{
}
}
},
"dto.ContainerPrune": {
"type": "object",
"required": [
"pruneType"
],
"properties": {
"pruneType": {
"type": "string",
"enum": [
"container",
"image",
"volume",
"network"
]
},
"withTagAll": {
"type": "boolean"
}
}
},
"dto.ContainerPruneReport": {
"type": "object",
"properties": {
"deletedNumber": {
"type": "integer"
},
"spaceReclaimed": {
"type": "integer"
}
}
},
"dto.ContainterStats": {
"type": "object",
"properties": {

View File

@ -1970,6 +1970,51 @@
}
}
},
"/containers/prune": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "容器清理",
"consumes": [
"application/json"
],
"tags": [
"Container"
],
"summary": "Clean container",
"parameters": [
{
"description": "request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerPrune"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerPruneReport"
}
}
},
"x-panel-log": {
"BeforeFuntions": [],
"bodyKeys": [
"pruneType"
],
"formatEN": "clean container [pruneType]",
"formatZH": "清理容器 [pruneType]",
"paramKeys": []
}
}
},
"/containers/repo": {
"get": {
"security": [
@ -10509,6 +10554,37 @@
}
}
},
"dto.ContainerPrune": {
"type": "object",
"required": [
"pruneType"
],
"properties": {
"pruneType": {
"type": "string",
"enum": [
"container",
"image",
"volume",
"network"
]
},
"withTagAll": {
"type": "boolean"
}
}
},
"dto.ContainerPruneReport": {
"type": "object",
"properties": {
"deletedNumber": {
"type": "integer"
},
"spaceReclaimed": {
"type": "integer"
}
}
},
"dto.ContainterStats": {
"type": "object",
"properties": {

View File

@ -321,6 +321,27 @@ definitions:
- name
- operation
type: object
dto.ContainerPrune:
properties:
pruneType:
enum:
- container
- image
- volume
- network
type: string
withTagAll:
type: boolean
required:
- pruneType
type: object
dto.ContainerPruneReport:
properties:
deletedNumber:
type: integer
spaceReclaimed:
type: integer
type: object
dto.ContainterStats:
properties:
cache:
@ -4516,6 +4537,35 @@ paths:
formatEN: container [operation] [name] [newName]
formatZH: 容器 [name] 执行 [operation] [newName]
paramKeys: []
/containers/prune:
post:
consumes:
- application/json
description: 容器清理
parameters:
- description: request
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.ContainerPrune'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.ContainerPruneReport'
security:
- ApiKeyAuth: []
summary: Clean container
tags:
- Container
x-panel-log:
BeforeFuntions: []
bodyKeys:
- pruneType
formatEN: clean container [pruneType]
formatZH: 清理容器 [pruneType]
paramKeys: []
/containers/repo:
get:
description: 获取镜像仓库列表

View File

@ -69,6 +69,14 @@ export namespace Container {
id: string;
type: string;
}
export interface ContainerPrune {
pruneType: string;
withTagAll: boolean;
}
export interface ContainerPruneReport {
deletedNumber: number;
spaceReclaimed: number;
}
export interface Options {
option: string;
}

View File

@ -20,6 +20,9 @@ export const ContainerStats = (id: string) => {
export const ContainerOperator = (params: Container.ContainerOperate) => {
return http.post(`/containers/operate`, params);
};
export const containerPrune = (params: Container.ContainerPrune) => {
return http.post<Container.ContainerPruneReport>(`/containers/prune`, params);
};
export const inspect = (params: Container.ContainerInspect) => {
return http.post<string>(`/containers/inspect`, params);
};

View File

@ -18,7 +18,7 @@
<el-button @click="onCancle">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="submitInput !== submitInputInfo" @click="onConfirm">
<el-button type="primary" :disabled="submitInput !== submitInputInfo" @click="onConfirm">
{{ $t('commons.button.confirm') }}
</el-button>
</span>

View File

@ -437,6 +437,20 @@ const message = {
unpause: 'Unpause',
rename: 'Rename',
remove: 'Remove',
containerPrune: 'Container prune',
containerPruneHelper: 'Remove all stopped containers. Do you want to continue?',
imagePrune: 'Image prune',
imagePruneSome: 'Clean unlabeled',
imagePruneSomeHelper: 'Remove all unused and unlabeled container images',
imagePruneAll: 'Clean unused',
imagePruneAllHelper: 'Remove all unused images, not just unlabeled',
networkPrune: 'Network prune',
networkPruneHelper: 'Remove all unused networks. Do you want to continue?',
volumePrune: 'Volue prune',
volumePruneHelper: 'Remove all unused local volumes. Do you want to continue?',
cleanSuccess: 'The operation is successful, the number of this cleanup: {0}!',
cleanSuccessWithSpace:
'The operation is successful. The number of disks cleared this time is {0}. The disk space freed is {1}!',
container: 'Container',
upTime: 'UpTime',
all: 'All',
@ -542,7 +556,7 @@ const message = {
repoHelper: 'Does it include a mirror repository/organization/project?',
auth: 'Auth',
mirrorHelper:
'If there are multiple mirrors, newlines must be displayed, for example:\nhttps://hub-mirror.c.163.com \nhttps://reg-mirror.qiniu.com',
'If there are multiple mirrors, newlines must be displayed, for example:\nhttp://xxxxxx.m.daocloud.io \nhttps://xxxxxx.mirror.aliyuncs.com',
registrieHelper:
'If multiple private repositories exist, newlines must be displayed, for example:\n172.16.10.111:8081 \n172.16.10.112:8081',

View File

@ -456,6 +456,19 @@ const message = {
unpause: '恢复',
rename: '重命名',
remove: '删除',
containerPrune: '清理容器',
containerPruneHelper: '清理容器 将删除所有处于停止状态的容器该操作无法回滚是否继续',
imagePrune: '清理镜像',
imagePruneSome: '未标签镜像',
imagePruneSomeHelper: '清理标签为 none 且未被任何容器使用的镜像',
imagePruneAll: '未使用镜像',
imagePruneAllHelper: '清理所有未被任何容器使用的镜像',
networkPrune: '清理网络',
networkPruneHelper: '清理网络 将删除所有未被使用的网络该操作无法回滚是否继续',
volumePrune: '清理存储卷',
volumePruneHelper: '清理存储卷 将删除所有未被使用的本地存储卷该操作无法回滚是否继续',
cleanSuccess: '操作成功本次清理数量: {0} ',
cleanSuccessWithSpace: '操作成功本次清理数量: {0} 释放磁盘空间: {1}',
container: '容器',
upTime: '运行时长',
all: '全部',

View File

@ -12,6 +12,9 @@
<el-button type="primary" @click="onCreate()">
{{ $t('container.createContainer') }}
</el-button>
<el-button type="primary" plain @click="onClean()">
{{ $t('container.containerPrune') }}
</el-button>
<el-button-group style="margin-left: 10px">
<el-button :disabled="checkStatus('start')" @click="onOperate('start')">
{{ $t('container.start') }}
@ -137,12 +140,13 @@ import TerminalDialog from '@/views/container/container/terminal/index.vue';
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
import Status from '@/components/status/index.vue';
import { reactive, onMounted, ref } from 'vue';
import { ContainerOperator, inspect, loadDockerStatus, searchContainer } from '@/api/modules/container';
import { ContainerOperator, containerPrune, inspect, loadDockerStatus, searchContainer } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import { ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import router from '@/routers';
import { MsgSuccess } from '@/utils/message';
import { computeSize } from '@/utils/util';
const loading = ref();
const data = ref();
@ -232,6 +236,34 @@ const onInspect = async (id: string) => {
mydetail.value!.acceptParams(param);
};
const onClean = () => {
ElMessageBox.confirm(i18n.global.t('container.containerPruneHelper'), i18n.global.t('container.containerPrune'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
loading.value = true;
let params = {
pruneType: 'container',
withTagAll: false,
};
await containerPrune(params)
.then((res) => {
loading.value = false;
MsgSuccess(
i18n.global.t('container.cleanSuccessWithSpace', [
res.data.deletedNumber,
computeSize(res.data.spaceReclaimed),
]),
);
search();
})
.catch(() => {
loading.value = false;
});
});
};
const checkStatus = (operation: string) => {
if (selects.value.length < 1) {
return true;

View File

@ -19,6 +19,9 @@
<el-button type="primary" plain @click="onOpenBuild">
{{ $t('container.imageBuild') }}
</el-button>
<el-button type="primary" plain @click="onOpenPrune()">
{{ $t('container.imagePrune') }}
</el-button>
</el-col>
<el-col :span="8">
<TableSetting @search="search()" />
@ -74,6 +77,7 @@
<Load ref="dialogLoadRef" @search="search" />
<Build ref="dialogBuildRef" @search="search" />
<Delete ref="dialogDeleteRef" @search="search" />
<Prune ref="dialogPruneRef" @search="search" />
</div>
</template>
@ -90,6 +94,7 @@ import Save from '@/views/container/image/save/index.vue';
import Load from '@/views/container/image/load/index.vue';
import Build from '@/views/container/image/build/index.vue';
import Delete from '@/views/container/image/delete/index.vue';
import Prune from '@/views/container/image/prune/index.vue';
import { searchImage, listImageRepo, loadDockerStatus, imageRemove } from '@/api/modules/container';
import i18n from '@/lang';
import router from '@/routers';
@ -134,6 +139,7 @@ const dialogLoadRef = ref();
const dialogSaveRef = ref();
const dialogBuildRef = ref();
const dialogDeleteRef = ref();
const dialogPruneRef = ref();
const search = async () => {
const repoSearch = {
@ -162,6 +168,10 @@ const onOpenBuild = () => {
dialogBuildRef.value!.acceptParams();
};
const onOpenPrune = () => {
dialogPruneRef.value!.acceptParams();
};
const onOpenload = () => {
dialogLoadRef.value!.acceptParams();
};

View File

@ -0,0 +1,76 @@
<template>
<el-dialog v-model="dialogVisiable" :destroy-on-close="true" :close-on-click-modal="false" width="30%">
<template #header>
<div class="card-header">
<span>{{ $t('container.imagePrune') }}</span>
</div>
</template>
<el-form ref="deleteForm" v-loading="loading">
<el-form-item>
<el-radio-group v-model="withTagAll">
<el-radio :label="false">{{ $t('container.imagePruneSome') }}</el-radio>
<el-radio :label="true">{{ $t('container.imagePruneAll') }}</el-radio>
</el-radio-group>
<span class="input-help">
{{ withTagAll ? $t('container.imagePruneAllHelper') : $t('container.imagePruneSomeHelper') }}
</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisiable = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="onClean" :disabled="loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { containerPrune } from '@/api/modules/container';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { computeSize } from '@/utils/util';
import { ref } from 'vue';
const dialogVisiable = ref(false);
const withTagAll = ref(false);
const loading = ref();
const acceptParams = (): void => {
dialogVisiable.value = true;
withTagAll.value = false;
};
const emit = defineEmits<{ (e: 'search'): void }>();
const onClean = async () => {
loading.value = true;
let params = {
pruneType: 'image',
withTagAll: withTagAll.value,
};
await containerPrune(params)
.then((res) => {
loading.value = false;
dialogVisiable.value = false;
MsgSuccess(
i18n.global.t('container.cleanSuccessWithSpace', [
res.data.deletedNumber,
computeSize(res.data.spaceReclaimed),
]),
);
emit('search');
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -13,7 +13,10 @@
<el-button type="primary" @click="onCreate()">
{{ $t('container.createNetwork') }}
</el-button>
<el-button type="primary" plain :disabled="selects.length === 0" @click="batchDelete(null)">
<el-button type="primary" plain @click="onClean()">
{{ $t('container.networkPrune') }}
</el-button>
<el-button :disabled="selects.length === 0" @click="batchDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
</el-col>
@ -96,11 +99,13 @@ import CreateDialog from '@/views/container/network/create/index.vue';
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
import { reactive, onMounted, ref } from 'vue';
import { dateFormat } from '@/utils/util';
import { deleteNetwork, searchNetwork, inspect, loadDockerStatus } from '@/api/modules/container';
import { deleteNetwork, searchNetwork, inspect, loadDockerStatus, containerPrune } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import i18n from '@/lang';
import { useDeleteData } from '@/hooks/use-delete-data';
import router from '@/routers';
import { ElMessageBox } from 'element-plus';
import { MsgSuccess } from '@/utils/message';
const loading = ref();
@ -145,6 +150,29 @@ const onCreate = async () => {
dialogCreateRef.value!.acceptParams();
};
const onClean = () => {
ElMessageBox.confirm(i18n.global.t('container.networkPruneHelper'), i18n.global.t('container.networkPrune'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
loading.value = true;
let params = {
pruneType: 'network',
withTagAll: false,
};
await containerPrune(params)
.then((res) => {
loading.value = false;
MsgSuccess(i18n.global.t('container.cleanSuccess', [res.data.deletedNumber]));
search();
})
.catch(() => {
loading.value = false;
});
});
};
function selectable(row) {
return !row.isSystem;
}

View File

@ -13,7 +13,10 @@
<el-button type="primary" @click="onCreate()">
{{ $t('container.createVolume') }}
</el-button>
<el-button type="primary" plain :disabled="selects.length === 0" @click="batchDelete(null)">
<el-button type="primary" plain @click="onClean()">
{{ $t('container.volumePrune') }}
</el-button>
<el-button :disabled="selects.length === 0" @click="batchDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
</el-col>
@ -80,12 +83,13 @@ import TableSetting from '@/components/table-setting/index.vue';
import CreateDialog from '@/views/container/volume/create/index.vue';
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
import { reactive, onMounted, ref } from 'vue';
import { dateFormat } from '@/utils/util';
import { deleteVolume, searchVolume, inspect, loadDockerStatus } from '@/api/modules/container';
import { computeSize, dateFormat } from '@/utils/util';
import { deleteVolume, searchVolume, inspect, loadDockerStatus, containerPrune } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import i18n from '@/lang';
import { useDeleteData } from '@/hooks/use-delete-data';
import router from '@/routers';
import { MsgSuccess } from '@/utils/message';
const loading = ref();
const detailInfo = ref();
@ -157,6 +161,34 @@ const onInspect = async (id: string) => {
codemirror.value!.acceptParams(param);
};
const onClean = () => {
ElMessageBox.confirm(i18n.global.t('container.volumePruneHelper'), i18n.global.t('container.volumePrune'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
loading.value = true;
let params = {
pruneType: 'volume',
withTagAll: false,
};
await containerPrune(params)
.then((res) => {
loading.value = false;
MsgSuccess(
i18n.global.t('container.cleanSuccessWithSpace', [
res.data.deletedNumber,
computeSize(res.data.spaceReclaimed),
]),
);
search();
})
.catch(() => {
loading.value = false;
});
});
};
const batchDelete = async (row: Container.VolumeInfo | null) => {
let names: Array<string> = [];
if (row === null) {

View File

@ -42,7 +42,7 @@
<el-input type="password" show-password clearable v-model="passForm.retryPassword" />
</el-form-item>
<el-form-item>
<el-button @click="submitChangePassword(passFormRef)">
<el-button type="primary" @click="submitChangePassword(passFormRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</el-form-item>