From 7596099aa15913465bdd63350a81c33c7245682b Mon Sep 17 00:00:00 2001 From: ssongliu <73214554+ssongliu@users.noreply.github.com> Date: Tue, 23 May 2023 19:00:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E3=80=81=E9=95=9C=E5=83=8F=E3=80=81=E7=BD=91=E7=BB=9C=E3=80=81?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=8D=B7=E6=B8=85=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20(#1117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/container.go | 27 +++++++ backend/app/dto/container.go | 10 +++ backend/app/service/container.go | 46 +++++++++++ backend/router/ro_container.go | 1 + cmd/server/docs/docs.go | 76 +++++++++++++++++++ cmd/server/docs/swagger.json | 76 +++++++++++++++++++ cmd/server/docs/swagger.yaml | 50 ++++++++++++ frontend/src/api/interface/container.ts | 8 ++ frontend/src/api/modules/container.ts | 3 + .../src/components/confirm-dialog/index.vue | 2 +- frontend/src/lang/modules/en.ts | 16 +++- frontend/src/lang/modules/zh.ts | 13 ++++ .../src/views/container/container/index.vue | 34 ++++++++- frontend/src/views/container/image/index.vue | 10 +++ .../src/views/container/image/prune/index.vue | 76 +++++++++++++++++++ .../src/views/container/network/index.vue | 32 +++++++- frontend/src/views/container/volume/index.vue | 38 +++++++++- frontend/src/views/setting/expired.vue | 2 +- 18 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 frontend/src/views/container/image/prune/index.vue diff --git a/backend/app/api/v1/container.go b/backend/app/api/v1/container.go index 2b1f73307..25581f255 100644 --- a/backend/app/api/v1/container.go +++ b/backend/app/api/v1/container.go @@ -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 清理容器日志 diff --git a/backend/app/dto/container.go b/backend/app/dto/container.go index b8c54eab6..01e7f2ffc 100644 --- a/backend/app/dto/container.go +++ b/backend/app/dto/container.go @@ -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"` diff --git a/backend/app/service/container.go b/backend/app/service/container.go index edcb72f79..bce66e555 100644 --- a/backend/app/service/container.go +++ b/backend/app/service/container.go @@ -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 { diff --git a/backend/router/ro_container.go b/backend/router/ro_container.go index d1612e9fa..7fc95f635 100644 --- a/backend/router/ro_container.go +++ b/backend/router/ro_container.go @@ -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) diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index 61918a005..3f1e4f5b8 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -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": { diff --git a/cmd/server/docs/swagger.json b/cmd/server/docs/swagger.json index 69c7f6597..1eddce5ec 100644 --- a/cmd/server/docs/swagger.json +++ b/cmd/server/docs/swagger.json @@ -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": { diff --git a/cmd/server/docs/swagger.yaml b/cmd/server/docs/swagger.yaml index 90e45d82e..a3e8f7aa6 100644 --- a/cmd/server/docs/swagger.yaml +++ b/cmd/server/docs/swagger.yaml @@ -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: 获取镜像仓库列表 diff --git a/frontend/src/api/interface/container.ts b/frontend/src/api/interface/container.ts index 8dd31935a..ba5e38170 100644 --- a/frontend/src/api/interface/container.ts +++ b/frontend/src/api/interface/container.ts @@ -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; } diff --git a/frontend/src/api/modules/container.ts b/frontend/src/api/modules/container.ts index ec15b00cc..330387d0a 100644 --- a/frontend/src/api/modules/container.ts +++ b/frontend/src/api/modules/container.ts @@ -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(`/containers/prune`, params); +}; export const inspect = (params: Container.ContainerInspect) => { return http.post(`/containers/inspect`, params); }; diff --git a/frontend/src/components/confirm-dialog/index.vue b/frontend/src/components/confirm-dialog/index.vue index 3dcdbe568..e9650b721 100644 --- a/frontend/src/components/confirm-dialog/index.vue +++ b/frontend/src/components/confirm-dialog/index.vue @@ -18,7 +18,7 @@ {{ $t('commons.button.cancel') }} - + {{ $t('commons.button.confirm') }} diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 36ae8840b..5444fea12 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -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', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index d40f54122..a2429de97 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -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: '全部', diff --git a/frontend/src/views/container/container/index.vue b/frontend/src/views/container/container/index.vue index 09ad53d97..874b88707 100644 --- a/frontend/src/views/container/container/index.vue +++ b/frontend/src/views/container/container/index.vue @@ -12,6 +12,9 @@ {{ $t('container.createContainer') }} + + {{ $t('container.containerPrune') }} + {{ $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; diff --git a/frontend/src/views/container/image/index.vue b/frontend/src/views/container/image/index.vue index 2564f89f8..fe229fb95 100644 --- a/frontend/src/views/container/image/index.vue +++ b/frontend/src/views/container/image/index.vue @@ -19,6 +19,9 @@ {{ $t('container.imageBuild') }} + + {{ $t('container.imagePrune') }} + @@ -74,6 +77,7 @@ + @@ -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(); }; diff --git a/frontend/src/views/container/image/prune/index.vue b/frontend/src/views/container/image/prune/index.vue new file mode 100644 index 000000000..522a078cd --- /dev/null +++ b/frontend/src/views/container/image/prune/index.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/container/network/index.vue b/frontend/src/views/container/network/index.vue index 50152c0a9..4349105b1 100644 --- a/frontend/src/views/container/network/index.vue +++ b/frontend/src/views/container/network/index.vue @@ -13,7 +13,10 @@ {{ $t('container.createNetwork') }} - + + {{ $t('container.networkPrune') }} + + {{ $t('commons.button.delete') }} @@ -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; } diff --git a/frontend/src/views/container/volume/index.vue b/frontend/src/views/container/volume/index.vue index d5f5a20f3..73d81e37e 100644 --- a/frontend/src/views/container/volume/index.vue +++ b/frontend/src/views/container/volume/index.vue @@ -13,7 +13,10 @@ {{ $t('container.createVolume') }} - + + {{ $t('container.volumePrune') }} + + {{ $t('commons.button.delete') }} @@ -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 = []; if (row === null) { diff --git a/frontend/src/views/setting/expired.vue b/frontend/src/views/setting/expired.vue index 7e07823d4..a1c1e799d 100644 --- a/frontend/src/views/setting/expired.vue +++ b/frontend/src/views/setting/expired.vue @@ -42,7 +42,7 @@ - + {{ $t('commons.button.confirm') }}