Add prune function to image and container

This commit is contained in:
cuigh 2021-12-23 15:25:51 +08:00
parent 70837391d1
commit bb48beec82
17 changed files with 234 additions and 41 deletions

View File

@ -18,6 +18,7 @@ type ContainerHandler struct {
Delete web.HandlerFunc `path:"/delete" method:"post" auth:"container.delete" desc:"delete container"`
FetchLogs web.HandlerFunc `path:"/fetch-logs" auth:"container.logs" desc:"fetch logs of container"`
Connect web.HandlerFunc `path:"/connect" auth:"container.execute" desc:"connect to a running container"`
Prune web.HandlerFunc `path:"/prune" method:"post" auth:"container.delete" desc:"delete unused containers"`
}
// NewContainer creates an instance of ContainerHandler
@ -28,6 +29,7 @@ func NewContainer(b biz.ContainerBiz) *ContainerHandler {
Delete: containerDelete(b),
FetchLogs: containerFetchLogs(b),
Connect: containerConnect(b),
Prune: containerPrune(b),
}
}
@ -78,11 +80,12 @@ func containerDelete(b biz.ContainerBiz) web.HandlerFunc {
type Args struct {
Node string `json:"node"`
ID string `json:"id"`
Name string `json:"name"`
}
return func(ctx web.Context) (err error) {
args := &Args{}
if err = ctx.Bind(args); err == nil {
err = b.Delete(args.Node, args.ID, ctx.User())
err = b.Delete(args.Node, args.ID, args.Name, ctx.User())
}
return ajax(ctx, err)
}
@ -214,3 +217,25 @@ func containerConnect(b biz.ContainerBiz) web.HandlerFunc {
return nil
}
}
func containerPrune(b biz.ContainerBiz) web.HandlerFunc {
type Args struct {
Node string `json:"node"`
}
return func(ctx web.Context) (err error) {
args := &Args{}
if err = ctx.Bind(args); err != nil {
return err
}
count, size, err := b.Prune(args.Node, ctx.User())
if err != nil {
return err
}
return success(ctx, data.Map{
"count": count,
"size": size,
})
}
}

View File

@ -11,6 +11,7 @@ type ImageHandler struct {
Search web.HandlerFunc `path:"/search" auth:"image.view" desc:"search images"`
Find web.HandlerFunc `path:"/find" auth:"image.view" desc:"find image by id"`
Delete web.HandlerFunc `path:"/delete" method:"post" auth:"image.delete" desc:"delete image"`
Prune web.HandlerFunc `path:"/prune" method:"post" auth:"image.delete" desc:"delete unused images"`
}
// NewImage creates an instance of ImageHandler
@ -19,6 +20,7 @@ func NewImage(b biz.ImageBiz) *ImageHandler {
Search: imageSearch(b),
Find: imageFind(b),
Delete: imageDelete(b),
Prune: imagePrune(b),
}
}
@ -77,3 +79,25 @@ func imageDelete(b biz.ImageBiz) web.HandlerFunc {
return ajax(ctx, err)
}
}
func imagePrune(b biz.ImageBiz) web.HandlerFunc {
type Args struct {
Node string `json:"node"`
}
return func(ctx web.Context) (err error) {
args := &Args{}
if err = ctx.Bind(args); err != nil {
return err
}
count, size, err := b.Prune(args.Node, ctx.User())
if err != nil {
return err
}
return success(ctx, data.Map{
"count": count,
"size": size,
})
}
}

View File

@ -103,14 +103,14 @@ func volumePrune(b biz.VolumeBiz) web.HandlerFunc {
return err
}
deletedVolumes, reclaimedSpace, err := b.Prune(args.Node, ctx.User())
count, size, err := b.Prune(args.Node, ctx.User())
if err != nil {
return err
}
return success(ctx, data.Map{
"deletedVolumes": deletedVolumes,
"reclaimedSpace": reclaimedSpace,
"count": count,
"size": size,
})
}
}

View File

@ -14,19 +14,21 @@ import (
type ContainerBiz interface {
Search(node, name, status string, pageIndex, pageSize int) ([]*Container, int, error)
Find(node, id string) (container *Container, raw string, err error)
Delete(node, id string, user web.User) (err error)
Delete(node, id, name string, user web.User) (err error)
FetchLogs(node, id string, lines int, timestamps bool) (stdout, stderr string, err error)
ExecCreate(node, id string, cmd string) (resp types.IDResponse, err error)
ExecAttach(node, id string) (resp types.HijackedResponse, err error)
ExecStart(node, id string) error
Prune(node string, user web.User) (count int, size uint64, err error)
}
func NewContainer(d *docker.Docker) ContainerBiz {
return &containerBiz{d: d}
func NewContainer(d *docker.Docker, eb EventBiz) ContainerBiz {
return &containerBiz{d: d, eb: eb}
}
type containerBiz struct {
d *docker.Docker
d *docker.Docker
eb EventBiz
}
func (b *containerBiz) Find(node, id string) (c *Container, raw string, err error) {
@ -58,11 +60,11 @@ func (b *containerBiz) Search(node, name, status string, pageIndex, pageSize int
return containers, total, nil
}
func (b *containerBiz) Delete(node, id string, user web.User) (err error) {
func (b *containerBiz) Delete(node, id, name string, user web.User) (err error) {
err = b.d.ContainerRemove(context.TODO(), node, id)
//if err == nil {
// Event.CreateContainer(model.EventActionDelete, id, user)
//}
if err == nil {
b.eb.CreateContainer(EventActionDelete, id, name, user)
}
return
}
@ -86,6 +88,15 @@ func (b *containerBiz) FetchLogs(node, id string, lines int, timestamps bool) (s
return stdout.String(), stderr.String(), nil
}
func (b *containerBiz) Prune(node string, user web.User) (count int, size uint64, err error) {
var report types.ContainersPruneReport
if report, err = b.d.ContainerPrune(context.TODO(), node); err == nil {
count, size = len(report.ContainersDeleted), report.SpaceReclaimed
b.eb.CreateContainer(EventActionPrune, "", "", user)
}
return
}
type Container struct {
ID string `json:"id"`
Name string `json:"name"`

View File

@ -21,6 +21,8 @@ const (
EventTypeStack EventType = "Stack"
EventTypeConfig EventType = "Config"
EventTypeSecret EventType = "Secret"
EventTypeImage EventType = "Image"
EventTypeContainer EventType = "Container"
EventTypeVolume EventType = "Volume"
EventTypeUser EventType = "User"
EventTypeRole EventType = "Role"
@ -54,6 +56,8 @@ type EventBiz interface {
CreateConfig(action EventAction, id, name string, user web.User)
CreateSecret(action EventAction, id, name string, user web.User)
CreateStack(action EventAction, name string, user web.User)
CreateImage(action EventAction, id string, user web.User)
CreateContainer(action EventAction, id, name string, user web.User)
CreateVolume(action EventAction, name string, user web.User)
CreateUser(action EventAction, id, name string, user web.User)
CreateRole(action EventAction, id, name string, user web.User)
@ -110,6 +114,14 @@ func (b *eventBiz) CreateNode(action EventAction, id, name string, user web.User
b.create(EventTypeNode, action, id, name, user)
}
func (b *eventBiz) CreateImage(action EventAction, id string, user web.User) {
b.create(EventTypeImage, action, id, "", user)
}
func (b *eventBiz) CreateContainer(action EventAction, id, name string, user web.User) {
b.create(EventTypeContainer, action, id, name, user)
}
func (b *eventBiz) CreateVolume(action EventAction, name string, user web.User) {
b.create(EventTypeVolume, action, name, name, user)
}

View File

@ -15,14 +15,16 @@ type ImageBiz interface {
Search(node, name string, pageIndex, pageSize int) ([]*Image, int, error)
Find(node, name string) (image *Image, raw string, err error)
Delete(node, id string, user web.User) (err error)
Prune(node string, user web.User) (count int, size uint64, err error)
}
func NewImage(d *docker.Docker) ImageBiz {
return &imageBiz{d: d}
func NewImage(d *docker.Docker, eb EventBiz) ImageBiz {
return &imageBiz{d: d, eb: eb}
}
type imageBiz struct {
d *docker.Docker
d *docker.Docker
eb EventBiz
}
func (b *imageBiz) Find(node, id string) (img *Image, raw string, err error) {
@ -62,9 +64,18 @@ func (b *imageBiz) Search(node, name string, pageIndex, pageSize int) (images []
func (b *imageBiz) Delete(node, id string, user web.User) (err error) {
err = b.d.ImageRemove(context.TODO(), node, id)
//if err == nil {
// Event.CreateImage(model.EventActionDelete, id, user)
//}
if err == nil {
b.eb.CreateImage(EventActionDelete, id, user)
}
return
}
func (b *imageBiz) Prune(node string, user web.User) (count int, size uint64, err error) {
var report types.ImagesPruneReport
if report, err = b.d.ImagePrune(context.TODO(), node); err == nil {
count, size = len(report.ImagesDeleted), report.SpaceReclaimed
b.eb.CreateImage(EventActionPrune, "", user)
}
return
}

View File

@ -16,7 +16,7 @@ type VolumeBiz interface {
Find(node, name string) (volume *Volume, raw string, err error)
Delete(node, name string, user web.User) (err error)
Create(volume *Volume, user web.User) (err error)
Prune(node string, user web.User) (deletedVolumes []string, reclaimedSpace uint64, err error)
Prune(node string, user web.User) (count int, size uint64, err error)
}
func NewVolume(d *docker.Docker, eb EventBiz) VolumeBiz {
@ -85,11 +85,11 @@ func (b *volumeBiz) Create(vol *Volume, user web.User) (err error) {
return
}
func (b *volumeBiz) Prune(node string, user web.User) (deletedVolumes []string, reclaimedSpace uint64, err error) {
func (b *volumeBiz) Prune(node string, user web.User) (count int, size uint64, err error) {
var report types.VolumesPruneReport
report, err = b.d.VolumePrune(context.TODO(), node)
if err == nil {
deletedVolumes, reclaimedSpace = report.VolumesDeleted, report.SpaceReclaimed
count, size = len(report.VolumesDeleted), report.SpaceReclaimed
b.eb.CreateVolume(EventActionPrune, "", user)
}
return

View File

@ -136,3 +136,12 @@ func (d *Docker) ContainerLogs(ctx context.Context, node, id string, lines int,
}
return
}
// ContainerPrune remove all unused containers.
func (d *Docker) ContainerPrune(ctx context.Context, node string) (report types.ContainersPruneReport, err error) {
var c *client.Client
if c, err = d.agent(node); err == nil {
report, err = c.ContainersPrune(ctx, filters.NewArgs())
}
return
}

View File

@ -59,3 +59,12 @@ func (d *Docker) ImageRemove(ctx context.Context, node, id string) error {
}
return err
}
// ImagePrune remove all unused images.
func (d *Docker) ImagePrune(ctx context.Context, node string) (report types.ImagesPruneReport, err error) {
var c *client.Client
if c, err = d.agent(node); err == nil {
report, err = c.ImagesPrune(ctx, filters.NewArgs(filters.Arg("dangling", "false")))
}
return
}

View File

@ -79,6 +79,13 @@ export class ContainerApi {
stderr: string;
}>('/container/fetch-logs', args)
}
prune(node: string) {
return ajax.post<{
count: number;
size: number;
}>('/container/prune', { node })
}
}
export default new ContainerApi

View File

@ -74,6 +74,13 @@ export class ImageApi {
delete(node: string, id: string, name: string) {
return ajax.post<Result<Object>>('/image/delete', { node, id, name })
}
prune(node: string) {
return ajax.post<{
count: number;
size: number;
}>('/image/prune', { node })
}
}
export default new ImageApi

View File

@ -36,11 +36,6 @@ export interface FindResult {
raw: string;
}
export interface PruneResult {
deletedVolumes: string[];
reclaimedSpace: number;
}
export class VolumeApi {
find(node: string, name: string) {
return ajax.get<FindResult>('/volume/find', { node, name })
@ -59,7 +54,10 @@ export class VolumeApi {
}
prune(node: string) {
return ajax.post<PruneResult>('/volume/prune', { node })
return ajax.post<{
count: number;
size: number;
}>('/volume/prune', { node })
}
}

View File

@ -233,6 +233,14 @@ export default {
"export_chart": {
"title": "Export chart",
},
"prune_image": {
"title": "Prune image",
"body": "Are you sure you want to clean up unused images?",
},
"prune_container": {
"title": "Prune container",
"body": "Are you sure you want to clean up stopped containers?",
},
"prune_volume": {
"title": "Prune volume",
"body": "Are you sure you want to clean up unused data volumes?",
@ -384,6 +392,8 @@ export default {
"service_notice": "This service belong to '%s' stack, usually you should modify original compose instead of updating it directly.",
"password_notice": "You are a LDAP user, can not modify password here.",
"setting_notice": "You must restart Swirl to activate modifications.",
"prune_image_success": "A total of {count} images are cleaned up to free up {size} of space.",
"prune_container_success": "A total of {count} containers are cleaned up to free up {size} of space.",
"prune_volume_success": "A total of {count} data volumes are cleaned up to free up {size} of space.",
"403": "Sorry, you don't have permission to access this page",
"404": "The page you are requesting has been moved, deleted, renamed, or may not exist",

View File

@ -233,6 +233,14 @@ export default {
"export_chart": {
"title": "导出图表",
},
"prune_image": {
"title": "清理镜像",
"body": "是否确实要清理未使用的镜像?",
},
"prune_container": {
"title": "清理容器",
"body": "是否确实要清理已停止的容器?",
},
"prune_volume": {
"title": "清理数据卷",
"body": "是否确实要清理未使用的数据卷?",
@ -384,6 +392,8 @@ export default {
"service_notice": "这个服务属于编排 {stack},通常你应该修改原始的编排而不是直接修改此服务信息,否则后续重新部署编排时这些修改将会丢失。",
"password_notice": "你是 LDAP 用户,不能在这里修改密码。",
"setting_notice": "你必须重启 Swirl 才能让设置生效。",
"prune_image_success": "共清理 {count} 个镜像,释放 {size} 空间。",
"prune_container_success": "共清理 {count} 个容器,释放 {size} 空间。",
"prune_volume_success": "共清理 {count} 个数据卷,释放 {size} 空间。",
"403": "很抱歉,你没有权限访问此页面",
"404": "您要请求的页面已被移动、删除、重命名或可能不存在",

View File

@ -1,5 +1,16 @@
<template>
<x-page-header />
<x-page-header>
<template #action>
<n-button secondary size="small" type="warning" @click="prune">
<template #icon>
<n-icon>
<close-icon />
</n-icon>
</template>
{{ t('buttons.prune') }}
</n-button>
</template>
</x-page-header>
<n-space class="page-body" vertical :size="12">
<n-space :size="12">
<n-select
@ -38,13 +49,15 @@ import {
NDataTable,
NInput,
NSelect,
NIcon,
} from "naive-ui";
import { CloseOutline as CloseIcon } from "@vicons/ionicons5";
import XPageHeader from "@/components/PageHeader.vue";
import containerApi from "@/api/container";
import type { Container } from "@/api/container";
import nodeApi from "@/api/node";
import { useDataTable } from "@/utils/data-table";
import { renderButton, renderLink, renderTag } from "@/utils/render";
import { formatSize, renderButton, renderLink, renderTag } from "@/utils/render";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@ -82,18 +95,35 @@ const columns = [
title: t('fields.actions'),
key: "actions",
render(i: Container, index: number) {
return renderButton('error', t('buttons.delete'), () => deleteContainer(i, index), t('prompts.delete'))
return renderButton('error', t('buttons.delete'), () => remove(i, index), t('prompts.delete'))
},
},
];
const { state, pagination, fetchData, changePageSize } = useDataTable(containerApi.search, filter, false)
async function deleteContainer(c: Container, index: number) {
async function remove(c: Container, index: number) {
const node = c.labels?.find(l => l.name === 'com.docker.swarm.node.id')
await containerApi.delete(node?.value || '', c.id, '');
await containerApi.delete(node?.value || '', c.id, c.name);
state.data.splice(index, 1)
}
async function prune() {
window.dialog.warning({
title: t('dialogs.prune_container.title'),
content: t('dialogs.prune_container.body'),
positiveText: t('buttons.confirm'),
negativeText: t('buttons.cancel'),
onPositiveClick: async () => {
const r = await containerApi.prune(filter.node);
window.message.info(t('texts.prune_container_success', {
count: r.data?.count,
size: formatSize(r.data?.size as number),
}));
fetchData();
}
})
}
onMounted(async () => {
const r = await nodeApi.list(true)
nodes.value = r.data?.map(n => ({ label: n.name, value: n.id }))

View File

@ -1,5 +1,16 @@
<template>
<x-page-header />
<x-page-header>
<template #action>
<n-button secondary size="small" type="warning" @click="prune">
<template #icon>
<n-icon>
<close-icon />
</n-icon>
</template>
{{ t('buttons.prune') }}
</n-button>
</template>
</x-page-header>
<n-space class="page-body" vertical :size="12">
<n-space :size="12">
<n-select
@ -38,7 +49,9 @@ import {
NDataTable,
NInput,
NSelect,
NIcon,
} from "naive-ui";
import { CloseOutline as CloseIcon } from "@vicons/ionicons5";
import XPageHeader from "@/components/PageHeader.vue";
import imageApi from "@/api/image";
import type { Image } from "@/api/image";
@ -86,17 +99,34 @@ const columns = [
title: t('fields.actions'),
key: "actions",
render(i: Image, index: number) {
return renderButton('error', t('buttons.delete'), () => deleteImage(i.id, index), t('prompts.delete'))
return renderButton('error', t('buttons.delete'), () => remove(i.id, index), t('prompts.delete'))
},
},
];
const { state, pagination, fetchData, changePageSize } = useDataTable(imageApi.search, filter, false)
async function deleteImage(id: string, index: number) {
async function remove(id: string, index: number) {
await imageApi.delete(filter.node, id, "");
state.data.splice(index, 1)
}
async function prune() {
window.dialog.warning({
title: t('dialogs.prune_image.title'),
content: t('dialogs.prune_image.body'),
positiveText: t('buttons.confirm'),
negativeText: t('buttons.cancel'),
onPositiveClick: async () => {
const r = await imageApi.prune(filter.node);
window.message.info(t('texts.prune_image_success', {
count: r.data?.count,
size: formatSize(r.data?.size as number),
}));
fetchData();
}
})
}
onMounted(async () => {
const r = await nodeApi.list(true)
nodes.value = r.data?.map(n => ({ label: n.name, value: n.id }))

View File

@ -1,7 +1,7 @@
<template>
<x-page-header>
<template #action>
<n-button secondary size="small" type="warning" @click="pruneVolume">
<n-button secondary size="small" type="warning" @click="prune">
<template #icon>
<n-icon>
<close-icon />
@ -107,18 +107,18 @@ const columns = [
title: t('fields.actions'),
key: "actions",
render(v: Volume, index: number) {
return renderButton('error', t('buttons.delete'), () => deleteVolume(v.name, index), t('prompts.delete'))
return renderButton('error', t('buttons.delete'), () => remove(v.name, index), t('prompts.delete'))
},
},
];
const { state, pagination, fetchData, changePageSize } = useDataTable(volumeApi.search, filter, false)
async function deleteVolume(name: string, index: number) {
async function remove(name: string, index: number) {
await volumeApi.delete(filter.node, name);
state.data.splice(index, 1)
}
async function pruneVolume() {
async function prune() {
window.dialog.warning({
title: t('dialogs.prune_volume.title'),
content: t('dialogs.prune_volume.body'),
@ -127,8 +127,8 @@ async function pruneVolume() {
onPositiveClick: async () => {
const r = await volumeApi.prune(filter.node);
window.message.info(t('texts.prune_volume_success', {
count: r.data?.deletedVolumes.length,
size: formatSize(r.data?.reclaimedSpace as number)
count: r.data?.count,
size: formatSize(r.data?.size as number)
}));
fetchData();
}