diff --git a/cmd/compose/stats.go b/cmd/compose/stats.go
index 887da083c2..f0be73739a 100644
--- a/cmd/compose/stats.go
+++ b/cmd/compose/stats.go
@@ -18,14 +18,10 @@ package compose
import (
"context"
- "fmt"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/container"
- "github.com/moby/moby/client"
"github.com/spf13/cobra"
-
- "github.com/docker/compose/v5/pkg/api"
)
type statsOptions struct {
@@ -34,6 +30,8 @@ type statsOptions struct {
format string
noStream bool
noTrunc bool
+ allProjects bool
+ aggregate bool
}
func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
@@ -45,6 +43,9 @@ func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
Short: "Display a live stream of container(s) resource usage statistics",
Args: cobra.MaximumNArgs(1),
RunE: Adapt(func(ctx context.Context, args []string) error {
+ if opts.allProjects || opts.aggregate {
+ return runStatsProjects(ctx, dockerCli, opts, args)
+ }
return runStats(ctx, dockerCli, opts, args)
}),
ValidArgsFunction: completeServiceNames(dockerCli, p),
@@ -59,6 +60,8 @@ func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates`)
flags.BoolVar(&opts.noStream, "no-stream", false, "Disable streaming stats and only pull the first result")
flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output")
+ flags.BoolVar(&opts.allProjects, "all-projects", false, "Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats)")
+ flags.BoolVar(&opts.aggregate, "aggregate", false, "Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats)")
return cmd
}
@@ -67,17 +70,11 @@ func runStats(ctx context.Context, dockerCli command.Cli, opts statsOptions, ser
if err != nil {
return err
}
- f := client.Filters{}
- f.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, name))
-
- if len(service) > 0 {
- f.Add("label", fmt.Sprintf("%s=%s", api.ServiceLabel, service[0]))
- }
return container.RunStats(ctx, dockerCli, &container.StatsOptions{
All: opts.all,
NoStream: opts.noStream,
NoTrunc: opts.noTrunc,
Format: opts.format,
- Filters: f,
+ Filters: projectFilters(name, service),
})
}
diff --git a/cmd/compose/stats_projects.go b/cmd/compose/stats_projects.go
new file mode 100644
index 0000000000..56a306a140
--- /dev/null
+++ b/cmd/compose/stats_projects.go
@@ -0,0 +1,340 @@
+/*
+ Copyright 2020 Docker Compose CLI authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package compose
+
+import (
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/docker/cli/cli/command"
+ "github.com/docker/go-units"
+ "github.com/moby/moby/api/types/container"
+ "github.com/moby/moby/client"
+ "golang.org/x/sync/errgroup"
+
+ "github.com/docker/compose/v5/cmd/formatter"
+ "github.com/docker/compose/v5/internal/naming"
+ "github.com/docker/compose/v5/pkg/api"
+)
+
+const statsProjectsRefreshInterval = 2 * time.Second
+
+// statsConcurrency bounds the samples collected at the same time. The daemon needs about a second to sample a
+// container, so a refresh of n containers lasts at least n/statsConcurrency seconds.
+const statsConcurrency = 32
+
+// usage is the resource consumption of one or several containers.
+type usage struct {
+ cpuPercent float64
+ memory float64
+ netRx float64
+ netTx float64
+ blockRead float64
+ blockWrite float64
+ pids uint64
+}
+
+func (u *usage) add(o usage) {
+ u.cpuPercent += o.cpuPercent
+ u.memory += o.memory
+ u.netRx += o.netRx
+ u.netTx += o.netTx
+ u.blockRead += o.blockRead
+ u.blockWrite += o.blockWrite
+ u.pids += o.pids
+}
+
+// containerStats is the resource consumption of a container.
+type containerStats struct {
+ project string
+ name string
+ memLimit float64
+ usage
+}
+
+// projectStats is the resource consumption of all the containers of a project.
+type projectStats struct {
+ name string
+ containers int
+ usage
+}
+
+type containerStatsView struct {
+ Project string
+ Name string
+ CPUPerc string
+ MemUsage string
+ MemPerc string
+ NetIO string
+ BlockIO string
+ PIDs uint64
+}
+
+type projectStatsView struct {
+ Project string
+ Containers int
+ CPUPerc string
+ MemUsage string
+ NetIO string
+ BlockIO string
+ PIDs uint64
+}
+
+func validateStatsProjects(opts statsOptions, services []string) error {
+ if len(services) > 0 {
+ var flags []string
+ if opts.allProjects {
+ flags = append(flags, "--all-projects")
+ }
+ if opts.aggregate {
+ flags = append(flags, "--aggregate")
+ }
+ return fmt.Errorf("%s report on whole projects and cannot be used with a SERVICE", strings.Join(flags, " and "))
+ }
+ if opts.allProjects && opts.ProjectOptions.ProjectName != "" {
+ return errors.New("--all-projects reports on all projects and cannot be used with --project-name")
+ }
+ return nil
+}
+
+func projectFilters(projectName string, services []string) client.Filters {
+ f := client.Filters{}.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, projectName))
+ if len(services) > 0 {
+ f.Add("label", fmt.Sprintf("%s=%s", api.ServiceLabel, services[0]))
+ }
+ return f
+}
+
+// runStatsProjects serves `stats --all-projects` and `stats --aggregate`: the per container or per project
+// view of either all the projects or the current one.
+func runStatsProjects(ctx context.Context, dockerCli command.Cli, opts statsOptions, services []string) error {
+ if err := validateStatsProjects(opts, services); err != nil {
+ return err
+ }
+ var filters client.Filters
+ if opts.allProjects {
+ filters = client.Filters{}.Add("label", api.ProjectLabel)
+ } else {
+ name, err := opts.ProjectOptions.toProjectName(ctx, dockerCli)
+ if err != nil {
+ return err
+ }
+ filters = projectFilters(name, nil)
+ }
+
+ render := func(out io.Writer, rows []containerStats) error {
+ if opts.aggregate {
+ return printProjectStats(out, opts.format, aggregateByProject(rows))
+ }
+ return printContainerStats(out, opts.format, rows)
+ }
+ // reject an unsupported format before sampling anything
+ if err := render(io.Discard, nil); err != nil {
+ return err
+ }
+
+ out := dockerCli.Out()
+ for {
+ rows, err := collectContainerStats(ctx, dockerCli.Client(), opts.all, filters)
+ if err != nil {
+ return err
+ }
+ if !opts.noStream && out.IsTerminal() {
+ _, _ = io.WriteString(out, "\033[H\033[J")
+ }
+ if err := render(out, rows); err != nil || opts.noStream {
+ return err
+ }
+
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-time.After(statsProjectsRefreshInterval):
+ }
+ }
+}
+
+// collectContainerStats samples the running containers matching filters. Containers are returned sorted by
+// project, then by name; the ones not running are returned without usage.
+func collectContainerStats(ctx context.Context, apiClient client.ContainerAPIClient, all bool, filters client.Filters) ([]containerStats, error) {
+ list, err := apiClient.ContainerList(ctx, client.ContainerListOptions{All: all, Filters: filters})
+ if err != nil {
+ return nil, err
+ }
+
+ rows := make([]containerStats, len(list.Items))
+ var eg errgroup.Group
+ eg.SetLimit(statsConcurrency)
+ for i, c := range list.Items {
+ rows[i] = containerStats{project: c.Labels[api.ProjectLabel], name: naming.CanonicalContainerName(c)}
+ if c.State != container.StateRunning {
+ continue
+ }
+ eg.Go(func() error {
+ // a container that cannot be sampled (removed since it was listed, daemon failure) is shown without
+ // usage rather than stopping the refresh of the others
+ if v, err := sampleStats(ctx, apiClient, c.ID); err == nil {
+ rows[i].usage = containerUsage(v)
+ rows[i].memLimit = float64(v.MemoryStats.Limit)
+ }
+ return nil
+ })
+ }
+ _ = eg.Wait() // the goroutines never fail
+ // an interrupted sampling is not a list of containers without usage
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ slices.SortFunc(rows, func(a, b containerStats) int {
+ return cmp.Or(strings.Compare(a.project, b.project), strings.Compare(a.name, b.name))
+ })
+ return rows, nil
+}
+
+// aggregateByProject sums the usage of the containers of each project. rows must be sorted by project.
+func aggregateByProject(rows []containerStats) []projectStats {
+ var result []projectStats
+ for _, r := range rows {
+ if len(result) == 0 || result[len(result)-1].name != r.project {
+ result = append(result, projectStats{name: r.project})
+ }
+ p := &result[len(result)-1]
+ p.containers++
+ p.add(r.usage)
+ }
+ return result
+}
+
+func sampleStats(ctx context.Context, apiClient client.ContainerAPIClient, id string) (container.StatsResponse, error) {
+ // a single sample is enough as long as the daemon provides the previous one to compute the CPU delta
+ res, err := apiClient.ContainerStats(ctx, id, client.ContainerStatsOptions{IncludePreviousSample: true})
+ if err != nil {
+ return container.StatsResponse{}, err
+ }
+ defer res.Body.Close() //nolint:errcheck
+
+ var v container.StatsResponse
+ err = json.NewDecoder(res.Body).Decode(&v)
+ return v, err
+}
+
+// containerUsage computes the same figures as `docker stats` for a Linux container. It follows the helpers of
+// docker/cli (cli/command/container/stats_helpers.go: calculateCPUPercentUnix, calculateMemUsageUnixNoCache,
+// calculateBlockIO and calculateNetwork), which must be kept in step with it.
+func containerUsage(v container.StatsResponse) usage {
+ u := usage{
+ memory: float64(v.MemoryStats.Usage),
+ pids: v.PidsStats.Current,
+ }
+
+ // page cache is not memory the container needs: cgroup v1 reports it as total_inactive_file, cgroup v2 as inactive_file
+ for _, key := range []string{"total_inactive_file", "inactive_file"} {
+ if cache, ok := v.MemoryStats.Stats[key]; ok && cache < v.MemoryStats.Usage {
+ u.memory = float64(v.MemoryStats.Usage - cache)
+ break
+ }
+ }
+
+ cpuDelta := float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
+ systemDelta := float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
+ onlineCPUs := float64(v.CPUStats.OnlineCPUs)
+ if onlineCPUs == 0 {
+ onlineCPUs = float64(len(v.CPUStats.CPUUsage.PercpuUsage))
+ }
+ if cpuDelta > 0 && systemDelta > 0 {
+ u.cpuPercent = cpuDelta / systemDelta * onlineCPUs * 100
+ }
+
+ for _, n := range v.Networks {
+ u.netRx += float64(n.RxBytes)
+ u.netTx += float64(n.TxBytes)
+ }
+ for _, e := range v.BlkioStats.IoServiceBytesRecursive {
+ switch {
+ case strings.EqualFold(e.Op, "read"):
+ u.blockRead += float64(e.Value)
+ case strings.EqualFold(e.Op, "write"):
+ u.blockWrite += float64(e.Value)
+ }
+ }
+ return u
+}
+
+func printContainerStats(out io.Writer, format string, rows []containerStats) error {
+ view := make([]containerStatsView, len(rows))
+ for i, r := range rows {
+ memPerc := 0.0
+ if r.memLimit > 0 {
+ memPerc = r.memory / r.memLimit * 100
+ }
+ view[i] = containerStatsView{
+ Project: r.project,
+ Name: r.name,
+ CPUPerc: fmt.Sprintf("%.2f%%", r.cpuPercent),
+ MemUsage: units.BytesSize(r.memory) + " / " + units.BytesSize(r.memLimit),
+ MemPerc: fmt.Sprintf("%.2f%%", memPerc),
+ NetIO: ioString(r.netRx, r.netTx),
+ BlockIO: ioString(r.blockRead, r.blockWrite),
+ PIDs: r.pids,
+ }
+ }
+ return formatter.Print(view, statsFormat(format), out, func(w io.Writer) {
+ for _, v := range view {
+ _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\n", v.Project, v.Name, v.CPUPerc, v.MemUsage, v.MemPerc, v.NetIO, v.BlockIO, v.PIDs)
+ }
+ }, "PROJECT", "NAME", "CPU %", "MEM USAGE / LIMIT", "MEM %", "NET I/O", "BLOCK I/O", "PIDS")
+}
+
+func printProjectStats(out io.Writer, format string, stats []projectStats) error {
+ view := make([]projectStatsView, len(stats))
+ for i, s := range stats {
+ view[i] = projectStatsView{
+ Project: s.name,
+ Containers: s.containers,
+ CPUPerc: fmt.Sprintf("%.2f%%", s.cpuPercent),
+ MemUsage: units.BytesSize(s.memory),
+ NetIO: ioString(s.netRx, s.netTx),
+ BlockIO: ioString(s.blockRead, s.blockWrite),
+ PIDs: s.pids,
+ }
+ }
+ return formatter.Print(view, statsFormat(format), out, func(w io.Writer) {
+ for _, v := range view {
+ _, _ = fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\t%d\n", v.Project, v.Containers, v.CPUPerc, v.MemUsage, v.NetIO, v.BlockIO, v.PIDs)
+ }
+ }, "PROJECT", "CONTAINERS", "CPU %", "MEM USAGE", "NET I/O", "BLOCK I/O", "PIDS")
+}
+
+// statsFormat makes the json format print one object per line, like `docker stats`, instead of an array.
+func statsFormat(format string) string {
+ if strings.EqualFold(format, formatter.JSON) {
+ return formatter.TemplateLegacyJSON
+ }
+ return format
+}
+
+func ioString(in, out float64) string {
+ return units.HumanSizeWithPrecision(in, 3) + " / " + units.HumanSizeWithPrecision(out, 3)
+}
diff --git a/cmd/compose/stats_projects_test.go b/cmd/compose/stats_projects_test.go
new file mode 100644
index 0000000000..e9f3c901d9
--- /dev/null
+++ b/cmd/compose/stats_projects_test.go
@@ -0,0 +1,384 @@
+/*
+ Copyright 2020 Docker Compose CLI authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package compose
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/containerd/errdefs"
+ "github.com/docker/cli/cli/streams"
+ "github.com/moby/moby/api/types/container"
+ "github.com/moby/moby/client"
+ "go.uber.org/mock/gomock"
+ "gotest.tools/v3/assert"
+
+ "github.com/docker/compose/v5/pkg/api"
+ "github.com/docker/compose/v5/pkg/mocks"
+)
+
+func statsBody(t *testing.T, v container.StatsResponse) client.ContainerStatsResult {
+ t.Helper()
+ b, err := json.Marshal(v)
+ assert.NilError(t, err)
+ return client.ContainerStatsResult{Body: io.NopCloser(bytes.NewReader(b))}
+}
+
+// sampleResponse builds a stats sample using 50% of one CPU (out of 2) over the last interval.
+func sampleResponse(memUsage, inactiveFile uint64) container.StatsResponse {
+ var v container.StatsResponse
+ v.PreCPUStats.CPUUsage.TotalUsage = 1_000
+ v.PreCPUStats.SystemUsage = 10_000
+ v.CPUStats.CPUUsage.TotalUsage = 1_500
+ v.CPUStats.SystemUsage = 12_000
+ v.CPUStats.OnlineCPUs = 2
+ v.MemoryStats.Usage = memUsage
+ v.MemoryStats.Limit = 10_000
+ v.MemoryStats.Stats = map[string]uint64{"inactive_file": inactiveFile}
+ v.Networks = map[string]container.NetworkStats{
+ "eth0": {RxBytes: 10, TxBytes: 20},
+ "eth1": {RxBytes: 1, TxBytes: 2},
+ }
+ v.BlkioStats.IoServiceBytesRecursive = []container.BlkioStatEntry{
+ {Op: "Read", Value: 100},
+ {Op: "Write", Value: 200},
+ {Op: "Total", Value: 300},
+ }
+ v.PidsStats.Current = 3
+ return v
+}
+
+func TestContainerUsage(t *testing.T) {
+ u := containerUsage(sampleResponse(1000, 400))
+
+ // (cpuDelta=500 / systemDelta=2000) * 2 cpus * 100
+ assert.Equal(t, u.cpuPercent, 50.0)
+ // page cache (inactive_file) is not counted as usage
+ assert.Equal(t, u.memory, 600.0)
+ assert.Equal(t, u.netRx, 11.0)
+ assert.Equal(t, u.netTx, 22.0)
+ assert.Equal(t, u.blockRead, 100.0)
+ assert.Equal(t, u.blockWrite, 200.0)
+ assert.Equal(t, u.pids, uint64(3))
+}
+
+func TestContainerUsageWithoutSystemDelta(t *testing.T) {
+ v := sampleResponse(1000, 0)
+ v.PreCPUStats.SystemUsage = v.CPUStats.SystemUsage // no previous sample
+ assert.Equal(t, containerUsage(v).cpuPercent, 0.0)
+}
+
+func projectContainer(name, project, state string) container.Summary {
+ return container.Summary{
+ ID: name + "-id",
+ // the canonical name comes with a link alias, which must not be picked
+ Names: []string{"/linked_by/" + name, "/" + name},
+ Labels: map[string]string{api.ProjectLabel: project},
+ State: container.ContainerState(state),
+ }
+}
+
+func TestCollectContainerStats(t *testing.T) {
+ apiClient := mocks.NewMockAPIClient(gomock.NewController(t))
+ filters := client.Filters{}.Add("label", api.ProjectLabel)
+
+ apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ any, opts client.ContainerListOptions) (client.ContainerListResult, error) {
+ assert.Assert(t, opts.All)
+ assert.DeepEqual(t, opts.Filters, filters)
+ return client.ContainerListResult{Items: []container.Summary{
+ projectContainer("beta-1", "beta", "running"),
+ projectContainer("alpha-2", "alpha", "running"),
+ projectContainer("alpha-1", "alpha", "running"),
+ projectContainer("alpha-3", "alpha", "exited"),
+ projectContainer("gone-1", "gone", "running"),
+ }}, nil
+ })
+ // only running containers are sampled, and the previous sample is needed for the CPU delta
+ for id, mem := range map[string]uint64{"alpha-1-id": 1000, "alpha-2-id": 3000, "beta-1-id": 500} {
+ apiClient.EXPECT().ContainerStats(gomock.Any(), id, client.ContainerStatsOptions{IncludePreviousSample: true}).
+ Return(statsBody(t, sampleResponse(mem, 0)), nil)
+ }
+ // removed between the list and the stats call: not an error
+ apiClient.EXPECT().ContainerStats(gomock.Any(), "gone-1-id", gomock.Any()).
+ Return(client.ContainerStatsResult{}, errdefs.ErrNotFound)
+
+ rows, err := collectContainerStats(t.Context(), apiClient, true, filters)
+ assert.NilError(t, err)
+
+ // sorted by project, then by container name
+ var names []string
+ for _, r := range rows {
+ names = append(names, r.project+"/"+r.name)
+ }
+ assert.DeepEqual(t, names, []string{"alpha/alpha-1", "alpha/alpha-2", "alpha/alpha-3", "beta/beta-1", "gone/gone-1"})
+
+ assert.Equal(t, rows[0].memory, 1000.0)
+ assert.Equal(t, rows[0].memLimit, 10_000.0)
+ assert.Equal(t, rows[1].memory, 3000.0)
+ // stopped and removed containers are still listed, without usage
+ assert.Equal(t, rows[2].memory, 0.0)
+ assert.Equal(t, rows[4].memory, 0.0)
+}
+
+// A container whose sampling fails is still listed, without usage, and does not stop the others.
+func TestCollectContainerStatsKeepsRowsWhenSamplingFails(t *testing.T) {
+ apiClient := mocks.NewMockAPIClient(gomock.NewController(t))
+ apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{
+ projectContainer("web-1", "app", "running"),
+ projectContainer("db-1", "app", "running"),
+ }}, nil)
+ apiClient.EXPECT().ContainerStats(gomock.Any(), "web-1-id", gomock.Any()).Return(client.ContainerStatsResult{}, errors.New("daemon unavailable"))
+ apiClient.EXPECT().ContainerStats(gomock.Any(), "db-1-id", gomock.Any()).Return(statsBody(t, sampleResponse(1000, 0)), nil)
+
+ rows, err := collectContainerStats(t.Context(), apiClient, false, client.Filters{})
+ assert.NilError(t, err)
+ assert.Equal(t, len(rows), 2)
+ assert.Equal(t, rows[0].name, "db-1")
+ assert.Equal(t, rows[0].memory, 1000.0)
+ assert.Equal(t, rows[1].name, "web-1")
+ assert.Equal(t, rows[1].memory, 0.0)
+}
+
+// Stopping the command interrupts the sampling, which must be reported as such rather than rendered as
+// containers without usage.
+func TestCollectContainerStatsReportsCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ apiClient := mocks.NewMockAPIClient(gomock.NewController(t))
+ apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{
+ projectContainer("web-1", "app", "running"),
+ }}, nil)
+ apiClient.EXPECT().ContainerStats(gomock.Any(), "web-1-id", gomock.Any()).DoAndReturn(
+ func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) {
+ cancel()
+ return client.ContainerStatsResult{}, ctx.Err()
+ })
+
+ _, err := collectContainerStats(ctx, apiClient, false, client.Filters{})
+ assert.ErrorIs(t, err, context.Canceled)
+}
+
+// The containers are sampled concurrently, but never more than statsConcurrency at once.
+func TestCollectContainerStatsBoundsConcurrency(t *testing.T) {
+ const containers = statsConcurrency + 8
+ var items []container.Summary
+ for i := range containers {
+ items = append(items, projectContainer(fmt.Sprintf("c-%02d", i), "app", "running"))
+ }
+ apiClient := mocks.NewMockAPIClient(gomock.NewController(t))
+ apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: items}, nil)
+
+ var inFlight, peak atomic.Int32
+ apiClient.EXPECT().ContainerStats(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) {
+ n := inFlight.Add(1)
+ defer inFlight.Add(-1)
+ for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() {
+ }
+ time.Sleep(20 * time.Millisecond)
+ return statsBody(t, sampleResponse(1, 0)), nil
+ }).Times(containers)
+
+ rows, err := collectContainerStats(t.Context(), apiClient, false, client.Filters{})
+ assert.NilError(t, err)
+ assert.Equal(t, len(rows), containers)
+ assert.Equal(t, peak.Load(), int32(statsConcurrency))
+}
+
+// --no-stream samples once and returns: the rows of all the projects, or one line per project with --aggregate.
+func TestRunStatsProjectsNoStream(t *testing.T) {
+ tests := []struct {
+ name string
+ aggregate bool
+ expected []string
+ }{
+ {"containers", false, []string{"alpha/alpha-1", "alpha/alpha-2", "beta/beta-1"}},
+ {"aggregate", true, []string{"alpha/2", "beta/1"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ apiClient := mocks.NewMockAPIClient(ctrl)
+ out := new(bytes.Buffer)
+ cli := mocks.NewMockCli(ctrl)
+ cli.EXPECT().Client().Return(apiClient).AnyTimes()
+ cli.EXPECT().Out().Return(streams.NewOut(out)).AnyTimes()
+
+ apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{
+ projectContainer("beta-1", "beta", "running"),
+ projectContainer("alpha-2", "alpha", "running"),
+ projectContainer("alpha-1", "alpha", "running"),
+ }}, nil)
+ apiClient.EXPECT().ContainerStats(gomock.Any(), gomock.Any(), gomock.Any()).
+ DoAndReturn(func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) {
+ return statsBody(t, sampleResponse(1000, 0)), nil
+ }).Times(3)
+
+ opts := statsOptions{ProjectOptions: &ProjectOptions{}, allProjects: true, aggregate: tt.aggregate, noStream: true, format: "json"}
+ assert.NilError(t, runStatsProjects(t.Context(), cli, opts, nil))
+
+ var got []string
+ for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
+ var row map[string]any
+ assert.NilError(t, json.Unmarshal([]byte(line), &row), line)
+ if tt.aggregate {
+ got = append(got, fmt.Sprintf("%s/%v", row["Project"], row["Containers"]))
+ } else {
+ got = append(got, fmt.Sprintf("%s/%s", row["Project"], row["Name"]))
+ }
+ }
+ assert.DeepEqual(t, got, tt.expected)
+ })
+ }
+}
+
+func TestAggregateByProject(t *testing.T) {
+ rows := []containerStats{
+ {project: "alpha", name: "alpha-1", usage: usage{cpuPercent: 50, memory: 1000, netRx: 11, pids: 3}},
+ {project: "alpha", name: "alpha-2", usage: usage{cpuPercent: 50, memory: 3000, netRx: 11, pids: 3}},
+ {project: "alpha", name: "alpha-3"},
+ {project: "beta", name: "beta-1", usage: usage{memory: 500}},
+ }
+
+ stats := aggregateByProject(rows)
+
+ assert.Equal(t, len(stats), 2)
+ assert.Equal(t, stats[0].name, "alpha")
+ assert.Equal(t, stats[0].containers, 3)
+ assert.Equal(t, stats[0].cpuPercent, 100.0)
+ assert.Equal(t, stats[0].memory, 4000.0)
+ assert.Equal(t, stats[0].netRx, 22.0)
+ assert.Equal(t, stats[0].pids, uint64(6))
+ assert.Equal(t, stats[1].name, "beta")
+ assert.Equal(t, stats[1].containers, 1)
+ assert.Equal(t, stats[1].memory, 500.0)
+}
+
+// decodeJSONLines decodes the output of the json format, which is one object per line like `docker stats`.
+func decodeJSONLines(t *testing.T, out string) []map[string]any {
+ t.Helper()
+ var decoded []map[string]any
+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
+ if line == "" {
+ continue
+ }
+ var obj map[string]any
+ assert.NilError(t, json.Unmarshal([]byte(line), &obj), line)
+ decoded = append(decoded, obj)
+ }
+ return decoded
+}
+
+func TestPrintContainerStats(t *testing.T) {
+ rows := []containerStats{{
+ project: "alpha",
+ name: "alpha-1",
+ memLimit: 10_000,
+ usage: usage{cpuPercent: 50, memory: 600, netRx: 11, netTx: 22, blockRead: 100, blockWrite: 200, pids: 3},
+ }}
+
+ var table bytes.Buffer
+ assert.NilError(t, printContainerStats(&table, "table", rows))
+ lines := strings.Split(strings.TrimSpace(table.String()), "\n")
+ assert.Equal(t, len(lines), 2)
+ assert.DeepEqual(t, strings.Fields(lines[0]), []string{"PROJECT", "NAME", "CPU", "%", "MEM", "USAGE", "/", "LIMIT", "MEM", "%", "NET", "I/O", "BLOCK", "I/O", "PIDS"})
+ assert.DeepEqual(t, strings.Fields(lines[1]), []string{"alpha", "alpha-1", "50.00%", "600B", "/", "9.766KiB", "6.00%", "11B", "/", "22B", "100B", "/", "200B", "3"})
+
+ var out bytes.Buffer
+ assert.NilError(t, printContainerStats(&out, "json", append(rows, containerStats{project: "beta", name: "beta-1"})))
+ decoded := decodeJSONLines(t, out.String())
+ assert.Equal(t, len(decoded), 2)
+ assert.Equal(t, decoded[0]["Project"], "alpha")
+ assert.Equal(t, decoded[0]["Name"], "alpha-1")
+ assert.Equal(t, decoded[0]["MemPerc"], "6.00%")
+ assert.Equal(t, decoded[1]["Name"], "beta-1")
+
+ // nothing to report, nothing printed
+ out.Reset()
+ assert.NilError(t, printContainerStats(&out, "json", nil))
+ assert.Equal(t, out.String(), "")
+}
+
+func TestPrintProjectStats(t *testing.T) {
+ stats := []projectStats{{name: "alpha", containers: 2, usage: usage{cpuPercent: 100, memory: 4000, pids: 6}}}
+
+ var table bytes.Buffer
+ assert.NilError(t, printProjectStats(&table, "table", stats))
+ lines := strings.Split(strings.TrimSpace(table.String()), "\n")
+ assert.Equal(t, len(lines), 2)
+ assert.DeepEqual(t, strings.Fields(lines[0])[:2], []string{"PROJECT", "CONTAINERS"})
+ assert.DeepEqual(t, strings.Fields(lines[1])[:3], []string{"alpha", "2", "100.00%"})
+
+ var out bytes.Buffer
+ assert.NilError(t, printProjectStats(&out, "json", append(stats, projectStats{name: "beta", containers: 1})))
+ decoded := decodeJSONLines(t, out.String())
+ assert.Equal(t, len(decoded), 2)
+ assert.Equal(t, decoded[1]["Project"], "beta")
+ assert.Equal(t, decoded[0]["Project"], "alpha")
+ assert.Equal(t, decoded[0]["Containers"], float64(2))
+ _, hasName := decoded[0]["Name"]
+ assert.Assert(t, !hasName)
+}
+
+func TestProjectFilters(t *testing.T) {
+ assert.DeepEqual(t, projectFilters("foo", nil), client.Filters{}.Add("label", api.ProjectLabel+"=foo"))
+ assert.DeepEqual(t, projectFilters("foo", []string{"web"}),
+ client.Filters{}.Add("label", api.ProjectLabel+"=foo", api.ServiceLabel+"=web"))
+}
+
+func TestValidateStatsProjects(t *testing.T) {
+ err := validateStatsProjects(statsOptions{ProjectOptions: &ProjectOptions{ProjectName: "foo"}, allProjects: true}, nil)
+ assert.ErrorContains(t, err, "--project-name")
+
+ // whole projects are reported, never a single service: the error names the flags that were given
+ tests := []struct {
+ opts statsOptions
+ message string
+ }{
+ {statsOptions{allProjects: true}, "--all-projects report on whole projects and cannot be used with a SERVICE"},
+ {statsOptions{aggregate: true}, "--aggregate report on whole projects and cannot be used with a SERVICE"},
+ {statsOptions{allProjects: true, aggregate: true}, "--all-projects and --aggregate report on whole projects and cannot be used with a SERVICE"},
+ }
+ for _, tt := range tests {
+ tt.opts.ProjectOptions = &ProjectOptions{}
+ assert.Error(t, validateStatsProjects(tt.opts, []string{"web"}), tt.message)
+ assert.NilError(t, validateStatsProjects(tt.opts, nil))
+ }
+
+ // --aggregate alone reports on the current project, which -p selects
+ assert.NilError(t, validateStatsProjects(statsOptions{ProjectOptions: &ProjectOptions{ProjectName: "foo"}, aggregate: true}, nil))
+}
+
+// An unsupported format must fail before anything is listed nor sampled: the mocks expect no call at all.
+func TestStatsProjectsRejectsUnsupportedFormatUpFront(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ cli := mocks.NewMockCli(ctrl)
+
+ for _, aggregate := range []bool{false, true} {
+ opts := statsOptions{ProjectOptions: &ProjectOptions{}, allProjects: true, aggregate: aggregate, format: "{{.Name}}"}
+ err := runStatsProjects(t.Context(), cli, opts, nil)
+ assert.ErrorIs(t, err, api.ErrParsingFailed)
+ }
+}
diff --git a/docs/reference/compose_stats.md b/docs/reference/compose_stats.md
index 78d44b8935..843dac86bb 100644
--- a/docs/reference/compose_stats.md
+++ b/docs/reference/compose_stats.md
@@ -5,13 +5,15 @@ Display a live stream of container(s) resource usage statistics
### Options
-| Name | Type | Default | Description |
-|:--------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `-a`, `--all` | `bool` | | Show all containers (default shows just running) |
-| `--dry-run` | `bool` | | Execute command in dry run mode |
-| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates |
-| `--no-stream` | `bool` | | Disable streaming stats and only pull the first result |
-| `--no-trunc` | `bool` | | Do not truncate output |
+| Name | Type | Default | Description |
+|:-----------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `--aggregate` | `bool` | | Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats) |
+| `-a`, `--all` | `bool` | | Show all containers (default shows just running) |
+| `--all-projects` | `bool` | | Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats) |
+| `--dry-run` | `bool` | | Execute command in dry run mode |
+| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates |
+| `--no-stream` | `bool` | | Disable streaming stats and only pull the first result |
+| `--no-trunc` | `bool` | | Do not truncate output |
diff --git a/docs/reference/docker_compose_stats.yaml b/docs/reference/docker_compose_stats.yaml
index e6854b05a2..e79888f5c5 100644
--- a/docs/reference/docker_compose_stats.yaml
+++ b/docs/reference/docker_compose_stats.yaml
@@ -5,6 +5,17 @@ usage: docker compose stats [OPTIONS] [SERVICE]
pname: docker compose
plink: docker_compose.yaml
options:
+ - option: aggregate
+ value_type: bool
+ default_value: "false"
+ description: |
+ Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats)
+ deprecated: false
+ hidden: false
+ experimental: false
+ experimentalcli: false
+ kubernetes: false
+ swarm: false
- option: all
shorthand: a
value_type: bool
@@ -16,6 +27,17 @@ options:
experimentalcli: false
kubernetes: false
swarm: false
+ - option: all-projects
+ value_type: bool
+ default_value: "false"
+ description: |
+ Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats)
+ deprecated: false
+ hidden: false
+ experimental: false
+ experimentalcli: false
+ kubernetes: false
+ swarm: false
- option: format
value_type: string
description: |-