diff --git a/agent/app/api/v2/container.go b/agent/app/api/v2/container.go index d8f43c678..2489819cf 100644 --- a/agent/app/api/v2/container.go +++ b/agent/app/api/v2/container.go @@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) { return } - total, list, err := containerService.Page(req) + total, list, err := containerService.Page(c.Request.Context(), req) if err != nil { helper.InternalServer(c, err) return @@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) { // @Success 200 {object} dto.ContainerStatus // @Security ApiKeyAuth // @Security Timestamp +// @Param containersOnly query boolean false "Only count containers" // @Router /containers/status [get] func (b *BaseApi) LoadContainerStatus(c *gin.Context) { - data, err := containerService.LoadStatus() + data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true") if err != nil { helper.InternalServer(c, err) return @@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) { // @Success 200 {array} dto.ContainerListStats // @Security ApiKeyAuth // @Security Timestamp +// @Param ids query string false "Comma-separated container IDs; omitted selects all containers" // @Router /containers/list/stats [get] func (b *BaseApi) ContainerListStats(c *gin.Context) { - data, err := containerService.ContainerListStats() + var ids []string + if _, supplied := c.Request.URL.Query()["ids"]; supplied { + ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' }) + } + data, err := containerService.ContainerListStats(c.Request.Context(), ids) if err != nil { helper.InternalServer(c, err) return @@ -965,24 +971,6 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) { containerService.StreamLogs(c, streamLog) } -// CleanNetworks submits a network cleanup task. -// @Tags Container Network -// @Summary Submit unused network cleanup task -// @Produce json -// @Success 200 {object} dto.NetworkCleanupTask -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /containers/network/clean [post] -// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清理未使用的容器网络","formatEN":"Clean unused container networks"} -func (b *BaseApi) CleanNetworks(c *gin.Context) { - result, err := containerService.CleanNetworks() - if err != nil { - helper.InternalServer(c, err) - return - } - helper.SuccessWithData(c, result) -} - func (b *BaseApi) CleanNetworks(c *gin.Context) { result, err := containerService.CleanNetworks() if err != nil { diff --git a/agent/app/service/container.go b/agent/app/service/container.go index 9866ba1cc..4b8949382 100644 --- a/agent/app/service/container.go +++ b/agent/app/service/container.go @@ -58,10 +58,10 @@ type ContainerService struct{} var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") type IContainerService interface { - Page(req dto.PageContainer) (int64, interface{}, error) + Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) List() []dto.ContainerOptions ListByImage(imageName string) []dto.ContainerOptions - LoadStatus() (dto.ContainerStatus, error) + LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) ListNetwork() ([]dto.Options, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error) @@ -80,7 +80,7 @@ type IContainerService interface { ContainerUpdate(req dto.ContainerOperate) error ContainerUpgrade(req dto.ContainerUpgrade) error ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) - ContainerListStats() ([]dto.ContainerListStats, error) + ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error) LoadResourceLimit() (*dto.ResourceLimit, error) ContainerRename(req dto.ContainerRename) error @@ -113,7 +113,9 @@ func NewIContainerService() IContainerService { return &ContainerService{} } -func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) { +func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() client, err := docker.NewDockerClient() if err != nil { return 0, nil, err @@ -124,7 +126,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro options.Filters = filters.NewArgs() options.Filters.Add("label", req.Filters) } - containers, err := client.ContainerList(context.Background(), options) + containers, err := client.ContainerList(ctx, options) if err != nil { return 0, nil, err } @@ -207,27 +209,32 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions return options } -func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) { +func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) { var data dto.ContainerStatus client, err := docker.NewDockerClient() if err != nil { return data, err } defer client.Close() - c := context.Background() - - images, _ := client.ImageList(c, image.ListOptions{All: true}) - data.ImageCount = len(images) - repo, _ := imageRepoRepo.List() - data.RepoCount = len(repo) - templates, _ := composeRepo.List() - data.ComposeTemplateCount = len(templates) - networks, _ := client.NetworkList(c, network.ListOptions{}) - data.NetworkCount = len(networks) - volumes, _ := client.VolumeList(c, volume.ListOptions{}) - data.VolumeCount = len(volumes.Volumes) - data.ComposeCount = loadComposeCount(client) - containers, _ := client.ContainerList(c, container.ListOptions{All: true}) + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if !containersOnly { + images, _ := client.ImageList(c, image.ListOptions{All: true}) + data.ImageCount = len(images) + repo, _ := imageRepoRepo.List() + data.RepoCount = len(repo) + templates, _ := composeRepo.List() + data.ComposeTemplateCount = len(templates) + networks, _ := client.NetworkList(c, network.ListOptions{}) + data.NetworkCount = len(networks) + volumes, _ := client.VolumeList(c, volume.ListOptions{}) + data.VolumeCount = len(volumes.Volumes) + data.ComposeCount = loadComposeCount(c, client) + } + containers, err := client.ContainerList(c, container.ListOptions{All: true}) + if err != nil { + return data, err + } data.ContainerCount = len(containers) for _, item := range containers { switch item.State { @@ -293,27 +300,67 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera } return data, nil } -func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) { +func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) { client, err := docker.NewDockerClient() if err != nil { return nil, err } defer client.Close() - list, err := client.ContainerList(context.Background(), container.ListOptions{All: true}) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + options := container.ListOptions{All: true} + if ids != nil { + if len(ids) == 0 { + return []dto.ContainerListStats{}, nil + } + options.Filters = filters.NewArgs() + for _, id := range ids { + options.Filters.Add("id", id) + } + } + list, err := client.ContainerList(ctx, options) if err != nil { return nil, err } + return collectContainerStats(ctx, list, func(ctx context.Context, id string) dto.ContainerListStats { + return loadCpuAndMem(ctx, client, id) + }), nil +} + +// A fixed worker pool bounds Docker stats requests, including for legacy callers +// that request all containers. Stopped containers do not need a stats sample. +func collectContainerStats(ctx context.Context, list []container.Summary, load func(context.Context, string) dto.ContainerListStats) []dto.ContainerListStats { datas := make([]dto.ContainerListStats, len(list)) - var wg sync.WaitGroup - wg.Add(len(list)) - for i := 0; i < len(list); i++ { - go func(index int, item container.Summary) { - datas[index] = loadCpuAndMem(client, item.ID) - wg.Done() - }(i, list[i]) + for i, item := range list { + datas[i].ContainerID = item.ID } + jobs := make(chan int) + var wg sync.WaitGroup + for worker := 0; worker < min(8, len(list)); worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobs { + if ctx.Err() != nil || list[index].State != "running" { + continue + } + sampleCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + datas[index] = load(sampleCtx, list[index].ID) + cancel() + } + }() + } +dispatch: + for index := range list { + select { + case <-ctx.Done(): + break dispatch + case jobs <- index: + } + } + close(jobs) wg.Wait() - return datas, nil + return datas } func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) { @@ -1716,11 +1763,11 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo return selected } -func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats { +func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats { data := dto.ContainerListStats{ ContainerID: containerItem, } - res, err := client.ContainerStats(context.Background(), containerItem, false) + res, err := client.ContainerStats(ctx, containerItem, false) if err != nil { return data } @@ -1943,11 +1990,11 @@ func transPortToStr(ports []container.Port) []string { return docker.SimplifyPorts(ports) } -func loadComposeCount(client *client.Client) int { +func loadComposeCount(ctx context.Context, client *client.Client) int { options := container.ListOptions{All: true} options.Filters = filters.NewArgs() options.Filters.Add("label", composeProjectLabel) - list, err := client.ContainerList(context.Background(), options) + list, err := client.ContainerList(ctx, options) if err != nil { return 0 } diff --git a/frontend/src/api/modules/container.ts b/frontend/src/api/modules/container.ts index 131f1c34e..0d0abbd65 100644 --- a/frontend/src/api/modules/container.ts +++ b/frontend/src/api/modules/container.ts @@ -3,13 +3,12 @@ import { ResPage, SearchWithPage } from '../interface'; import { Container } from '../interface/container'; import { TimeoutEnum } from '@/enums/http-enum'; -export const searchContainer = (params: Container.ContainerSearch, currentNode?: string) => { - return http.post>( - `/containers/search`, - params, - TimeoutEnum.T_40S, - currentNode ? { CurrentNode: currentNode } : undefined, - ); +export const searchContainer = (params: Container.ContainerSearch, currentNode?: string, signal?: AbortSignal) => { + return http.postWithConfig>(`/containers/search`, params, { + timeout: TimeoutEnum.T_40S, + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const listContainer = () => { return http.post>(`/containers/list`, {}); @@ -44,12 +43,11 @@ export const downloadContainerFile = (params: { containerID: string; path: strin timeout: TimeoutEnum.T_40S, }); }; -export const loadContainerStatus = (currentNode?: string) => { - return http.get( - `/containers/status`, - {}, - currentNode ? { headers: { CurrentNode: currentNode } } : {}, - ); +export const loadContainerStatus = (currentNode?: string, containersOnly = false, signal?: AbortSignal) => { + return http.get(`/containers/status`, containersOnly ? { containersOnly: true } : {}, { + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const loadResourceLimit = () => { return http.get(`/containers/limit`); @@ -90,12 +88,11 @@ export const containerItemStats = (containerID: string, currentNode?: string) => currentNode ? { CurrentNode: currentNode } : undefined, ); }; -export const containerListStats = (currentNode?: string) => { - return http.get>( - `/containers/list/stats`, - {}, - currentNode ? { headers: { CurrentNode: currentNode } } : {}, - ); +export const containerListStats = (currentNode?: string, ids?: string[], signal?: AbortSignal) => { + return http.get>(`/containers/list/stats`, ids ? { ids: ids.join(',') } : {}, { + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const containerStats = (id: string) => { return http.get(`/containers/stats/${id}`); diff --git a/frontend/src/views/container/container/index.vue b/frontend/src/views/container/container/index.vue index e0dbc46df..0c0b83807 100644 --- a/frontend/src/views/container/container/index.vue +++ b/frontend/src/views/container/container/index.vue @@ -488,7 +488,7 @@ import Uploads from '@/components/upload/index.vue'; import DockerStatus from '@/views/container/docker-status/index.vue'; import ContainerLogDialog from '@/components/log/container-drawer/index.vue'; import Status from '@/components/status/index.vue'; -import { computed, reactive, ref } from 'vue'; +import { computed, onBeforeUnmount, reactive, ref } from 'vue'; import { containerItemStats, containerListStats, @@ -700,47 +700,87 @@ const dialogContainerLogRef = ref(); const dialogRenameRef = ref(); const dialogPruneRef = ref(); -const search = async (column?: any) => { - if (!isActive.value || !isExist.value) { +let requestController: AbortController | undefined; +let requestVersion = 0; +let refreshPending = false; + +onBeforeUnmount(() => { + requestVersion++; + requestController?.abort(); +}); + +const loadContainers = async (background = false, column?: any) => { + if (!isActive.value || !isExist.value || (background && refreshPending)) { return; } + requestController?.abort(); + const controller = new AbortController(); + requestController = controller; + const version = ++requestVersion; + const isCurrent = () => version === requestVersion && !controller.signal.aborted; + refreshPending = true; + loading.value = !background; localStorage.setItem('includeAppStore', includeAppStore.value ? 'true' : 'false'); - let filterItem = (router.currentRoute.value.query?.filters as string) || ''; paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.order = column?.order ? column.order : paginationConfig.order; - let params = { + const params = { name: searchName.value, state: paginationConfig.state || 'all', page: paginationConfig.currentPage, pageSize: paginationConfig.pageSize, - filters: filterItem, + filters: props.filters || (router.currentRoute.value.query?.filters as string) || '', orderBy: paginationConfig.orderBy, order: paginationConfig.order, excludeAppStore: !includeAppStore.value, }; - loading.value = true; - const [containerResult, statsResult, statusResult] = await Promise.allSettled([ - searchContainer(params), - containerListStats(), - loadContainerStatus(), - ]); - loading.value = false; - - if (containerResult.status === 'fulfilled') { - const containers = containerResult.value.data.items || []; + // Counts and resource samples must never hold up the table itself. + const statusRequest = loadContainerStatus(undefined, true, controller.signal) + .then((res) => { + if (isCurrent()) { + updateTags(res.data || {}); + } + }) + .catch(() => {}); + try { + const res = await searchContainer(params, undefined, controller.signal); + if (!isCurrent()) { + return; + } + const containers = res.data.items || []; syncContainerRows(containers); - paginationConfig.total = containerResult.value.data.total; - } - - if (statsResult.status === 'fulfilled') { - applyStatsToRows(statsResult.value.data || []); - } - - if (statusResult.status === 'fulfilled') { - updateTags(statusResult.value.data || {}); + paginationConfig.total = res.data.total; + loading.value = false; + // Clear old usage when a previously running container has stopped. + applyStatsToRows( + containers + .filter((item) => item.state !== 'running') + .map((item) => ({ + containerID: item.containerID, + ...Object.fromEntries(statFields.map((field) => [field, 0])), + })), + ); + const ids = containers.filter((item) => item.state === 'running').map((item) => item.containerID); + if (ids.length > 0) { + const stats = await containerListStats(undefined, ids, controller.signal); + if (isCurrent()) { + applyStatsToRows(stats.data || []); + } + } + } catch { + // The HTTP layer reports failures; keep the last successful rows visible. + } finally { + if (isCurrent()) { + loading.value = false; + } + await statusRequest; + if (isCurrent()) { + refreshPending = false; + } } }; +const search = (column?: any) => loadContainers(false, column); + const searchWithStatus = (item: string) => { activeTag.value = item; paginationConfig.state = activeTag.value; @@ -769,21 +809,7 @@ const changePinned = (row: any, isPinned: boolean) => { }); }; -const refresh = async () => { - let filterItem = props.filters ? props.filters : ''; - let params = { - name: searchName.value, - state: paginationConfig.state || 'all', - page: paginationConfig.currentPage, - pageSize: paginationConfig.pageSize, - filters: filterItem, - orderBy: paginationConfig.orderBy, - order: paginationConfig.order, - }; - const [containerResult, statsResult] = await Promise.all([searchContainer(params), containerListStats()]); - syncContainerRows(containerResult.data.items || []); - applyStatsToRows(statsResult.data || []); -}; +const refresh = () => loadContainers(true); const loadSize = async (row: any) => { containerItemStats(row.containerID).then((res) => {