Repository: docker/compose

Issue:
Condense output of `docker compose top`
### Description

In a current compose project, `docker compose top` reports the following:

```console
$ docker compose top
myproject-converters-1
UID    PID      PPID     C    STIME   TTY   TIME       CMD
root   336052   335954   0    11:18   ?     00:00:01   ruby bin/server

myproject-redis-ephemeral-1
UID        PID      PPID     C    STIME   TTY   TIME       CMD
systemd+   336001   335887   0    11:18   ?     00:00:05   valkey-server *:6379

myproject-redis-persisted-1
UID    PID      PPID     C    STIME   TTY   TIME       CMD
root   336038   335921   0    11:18   ?     00:00:08   valkey-server *:6379

myproject-sidekiq-default-1
UID      PID      PPID     C    STIME   TTY   TIME       CMD
appuser   338516   338496   19   12:00   ?     00:00:09   sidekiq 7.3.6 default [0 of 3 busy]

myproject-watchtower-1
UID    PID      PPID     C    STIME   TTY   TIME       CMD
root   335931   335850   0    11:18   ?     00:00:00   /watchtower --label-enable --include-stopped --revive-stopped --cleanup --rolling-restart --stop-timeout=80s --http-api-update

myproject-web-1
UID      PID      PPID     C    STIME   TTY   TIME       CMD
appuser   338619   338597   0    12:00   ?     00:00:00   /bin/bash -e /rails/bin/docker-entrypoint.sh rails server
appuser   338653   338619   0    12:00   ?     00:00:00   flock -x /rails/data/migration-check.lock bundle exec rake db:prepare
appuser   338665   338653   46   12:00   ?     00:00:01   /usr/local/bundle/ruby/3.2.0/bin/rake db:prepare
```

I find this a bit wasteful, in regards to the screen estate. The output is also hard to grasp because the columns are not aligned across the various services.

Would you accept a PR to format the output to something like the following?

```console
$ docker compose top
SERVICE                       UID        PID      PPID     C    STIME   TTY   TIME       CMD
myproject-converters-1        root       336052   335954   0    11:18   ?     00:00:01   ruby bin/server
myproject-redis-ephemeral-1   systemd+   336001   335887   0    11:18   ?     00:00:05   valkey-server *:6379
myproject-redis-persisted-1   root       336038   335921   0    11:18   ?     00:00:08   valkey-server *:6379
myproject-sidekiq-default-1   appuser    338516   338496   19   12:00   ?     00:00:09   sidekiq 7.3.6 default [0 of 3 busy]
myproject-watchtower-1        root       335931   335850   0    11:18   ?     00:00:00   /watchtower --label-enable --include-stopped --revive-stopped --cleanup --rolling-restart --stop-timeout=80s --http-api-update
myproject-web-1               appuser    338619   338597   0    12:00   ?     00:00:00   /bin/bash -e /rails/bin/docker-entrypoint.sh rails server
myproject-web-1               appuser    338653   338619   0    12:00   ?     00:00:00   flock -x /rails/data/migration-check.lock bundle exec rake db:prepare
myproject-web-1               appuser    338665   338653   46   12:00   ?     00:00:01   /usr/local/bundle/ruby/3.2.0/bin/rake db:prepare
```



Discussion:
@ndeloof: `compose top` uses `text/tabwriter` which _should_ manage alignment
columns are those reported by docker engine `ContainerTop` API, I agree we miss one to report the service process belongs to. Happy to review a PR if you can prepare one@dmke: Can I assume that `ContainerTop` returns always returns the columns in the same order, or shall I be defensive and ensure ordering of the `container.Titles` when running `compose top`?@dmke: @ndeloof, I have posted a first draft in #12391. Let me know what you think.

Solution patch:
diff --git a/cmd/compose/top.go b/cmd/compose/top.go
index 379b2b0c..a4cbc0f4 100644
--- a/cmd/compose/top.go
+++ b/cmd/compose/top.go
@@ -49,6 +49,11 @@ func topCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *
 	return topCmd
 }
 
+type (
+	topHeader  map[string]int // maps a proc title to its output index
+	topEntries map[string]string
+)
+
 func runTop(ctx context.Context, dockerCli command.Cli, backend api.Service, opts topOptions, services []string) error {
 	projectName, err := opts.toProjectName(ctx, dockerCli)
 	if err != nil {
@@ -63,30 +68,76 @@ func runTop(ctx context.Context, dockerCli command.Cli, backend api.Service, opt
 		return containers[i].Name < containers[j].Name
 	})
 
+	header, entries := collectTop(containers)
+	return topPrint(dockerCli.Out(), header, entries)
+}
+
+func collectTop(containers []api.ContainerProcSummary) (topHeader, []topEntries) {
+	// map column name to its header (should keep working if backend.Top returns
+	// varying columns for different containers)
+	header := topHeader{"SERVICE": 0, "#": 1}
+
+	// assume one process per container and grow if needed
+	entries := make([]topEntries, 0, len(containers))
+
 	for _, container := range containers {
-		_, _ = fmt.Fprintf(dockerCli.Out(), "%s\n", container.Name)
-		err := psPrinter(dockerCli.Out(), func(w io.Writer) {
-			for _, proc := range container.Processes {
-				info := []interface{}{}
-				for _, p := range proc {
-					info = append(info, p)
+		for _, proc := range container.Processes {
+			entry := topEntries{
+				"SERVICE": container.Service,
+				"#":       container.Replica,
+			}
+			for i, title := range container.Titles {
+				if _, exists := header[title]; !exists {
+					header[title] = len(header)
 				}
-				_, _ = fmt.Fprintf(w, strings.Repeat("%s\t", len(info))+"\n", info...)
+				entry[title] = proc[i]
+			}
+			entries = append(entries, entry)
+		}
+	}
 
+	// ensure CMD is the right-most column
+	if pos, ok := header["CMD"]; ok {
+		maxPos := pos
+		for h, i := range header {
+			if i > maxPos {
+				maxPos = i
+			}
+			if i > pos {
+				header[h] = i - 1
 			}
-			_, _ = fmt.Fprintln(w)
-		},
-			container.Titles...)
-		if err != nil {
-			return err
 		}
+		header["CMD"] = maxPos
 	}
-	return nil
+
+	return header, entries
 }
 
-func psPrinter(out io.Writer, printer func(writer io.Writer), headers ...string) error {
-	w := tabwriter.NewWriter(out, 5, 1, 3, ' ', 0)
-	_, _ = fmt.Fprintln(w, strings.Join(headers, "\t"))
-	printer(w)
+func topPrint(out io.Writer, headers topHeader, rows []topEntries) error {
+	if len(rows) == 0 {
+		return nil
+	}
+
+	w := tabwriter.NewWriter(out, 4, 1, 2, ' ', 0)
+
+	// write headers in the order we've encountered them
+	h := make([]string, len(headers))
+	for title, index := range headers {
+		h[index] = title
+	}
+	_, _ = fmt.Fprintln(w, strings.Join(h, "\t"))
+
+	for _, row := range rows {
+		// write proc data in header order
+		r := make([]string, len(headers))
+		for title, index := range headers {
+			if v, ok := row[title]; ok {
+				r[index] = v
+			} else {
+				r[index] = "-"
+			}
+		}
+		_, _ = fmt.Fprintln(w, strings.Join(r, "\t"))
+	}
 	return w.Flush()
 }
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 4f59a30f..86bb04d2 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -523,6 +523,8 @@ type ContainerProcSummary struct {
 	Name      string
 	Processes [][]string
 	Titles    []string
+	Service   string
+	Replica   string
 }
 
 // ImageSummary holds container image description
diff --git a/pkg/compose/top.go b/pkg/compose/top.go
index b615870a..fac7f7cb 100644
--- a/pkg/compose/top.go
+++ b/pkg/compose/top.go
@@ -42,12 +42,21 @@ func (s *composeService) Top(ctx context.Context, projectName string, services [
 			if err != nil {
 				return err
 			}
-			summary[i] = api.ContainerProcSummary{
+			name := getCanonicalContainerName(ctr)
+			s := api.ContainerProcSummary{
 				ID:        ctr.ID,
-				Name:      getCanonicalContainerName(ctr),
+				Name:      name,
 				Processes: topContent.Processes,
 				Titles:    topContent.Titles,
+				Service:   name,
 			}
+			if service, exists := ctr.Labels[api.ServiceLabel]; exists {
+				s.Service = service
+			}
+			if replica, exists := ctr.Labels[api.ContainerNumberLabel]; exists {
+				s.Replica = replica
+			}
+			summary[i] = s
 			return nil
 		})
 	}



Test patch:
diff --git a/cmd/compose/top_test.go b/cmd/compose/top_test.go
new file mode 100644
index 00000000..27721990
--- /dev/null
+++ b/cmd/compose/top_test.go
@@ -0,0 +1,329 @@
+/*
+   Copyright 2024 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"
+	"strings"
+	"testing"
+
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var topTestCases = []struct {
+	name   string
+	titles []string
+	procs  [][]string
+
+	header  topHeader
+	entries []topEntries
+	output  string
+}{
+	{
+		name:    "noprocs",
+		titles:  []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
+		procs:   [][]string{},
+		header:  topHeader{"SERVICE": 0, "#": 1},
+		entries: []topEntries{},
+		output:  "",
+	},
+	{
+		name:   "simple",
+		titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
+		procs:  [][]string{{"root", "1", "1", "0", "12:00", "?", "00:00:01", "/entrypoint"}},
+		header: topHeader{
+			"SERVICE": 0,
+			"#":       1,
+			"UID":     2,
+			"PID":     3,
+			"PPID":    4,
+			"C":       5,
+			"STIME":   6,
+			"TTY":     7,
+			"TIME":    8,
+			"CMD":     9,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "simple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:01",
+				"CMD":     "/entrypoint",
+			},
+		},
+		output: trim(`
+			SERVICE  #   UID   PID  PPID  C   STIME  TTY  TIME      CMD
+			simple   1   root  1    1     0   12:00  ?    00:00:01  /entrypoint
+		`),
+	},
+	{
+		name:   "noppid",
+		titles: []string{"UID", "PID", "C", "STIME", "TTY", "TIME", "CMD"},
+		procs:  [][]string{{"root", "1", "0", "12:00", "?", "00:00:02", "/entrypoint"}},
+		header: topHeader{
+			"SERVICE": 0,
+			"#":       1,
+			"UID":     2,
+			"PID":     3,
+			"C":       4,
+			"STIME":   5,
+			"TTY":     6,
+			"TIME":    7,
+			"CMD":     8,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "noppid",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:02",
+				"CMD":     "/entrypoint",
+			},
+		},
+		output: trim(`
+			SERVICE  #   UID   PID  C   STIME  TTY  TIME      CMD
+			noppid   1   root  1    0   12:00  ?    00:00:02  /entrypoint
+		`),
+	},
+	{
+		name:   "extra-hdr",
+		titles: []string{"UID", "GID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
+		procs:  [][]string{{"root", "1", "1", "1", "0", "12:00", "?", "00:00:03", "/entrypoint"}},
+		header: topHeader{
+			"SERVICE": 0,
+			"#":       1,
+			"UID":     2,
+			"GID":     3,
+			"PID":     4,
+			"PPID":    5,
+			"C":       6,
+			"STIME":   7,
+			"TTY":     8,
+			"TIME":    9,
+			"CMD":     10,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "extra-hdr",
+				"#":       "1",
+				"UID":     "root",
+				"GID":     "1",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:03",
+				"CMD":     "/entrypoint",
+			},
+		},
+		output: trim(`
+			SERVICE    #   UID   GID  PID  PPID  C   STIME  TTY  TIME      CMD
+			extra-hdr  1   root  1    1    1     0   12:00  ?    00:00:03  /entrypoint
+		`),
+	},
+	{
+		name:   "multiple",
+		titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"},
+		procs: [][]string{
+			{"root", "1", "1", "0", "12:00", "?", "00:00:04", "/entrypoint"},
+			{"root", "123", "1", "0", "12:00", "?", "00:00:42", "sleep infinity"},
+		},
+		header: topHeader{
+			"SERVICE": 0,
+			"#":       1,
+			"UID":     2,
+			"PID":     3,
+			"PPID":    4,
+			"C":       5,
+			"STIME":   6,
+			"TTY":     7,
+			"TIME":    8,
+			"CMD":     9,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "multiple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:04",
+				"CMD":     "/entrypoint",
+			},
+			{
+				"SERVICE": "multiple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "123",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:42",
+				"CMD":     "sleep infinity",
+			},
+		},
+		output: trim(`
+			SERVICE   #   UID   PID  PPID  C   STIME  TTY  TIME      CMD
+			multiple  1   root  1    1     0   12:00  ?    00:00:04  /entrypoint
+			multiple  1   root  123  1     0   12:00  ?    00:00:42  sleep infinity
+		`),
+	},
+}
+
+// TestRunTopCore only tests the core functionality of runTop: formatting
+// and printing of the output of (api.Service).Top().
+func TestRunTopCore(t *testing.T) {
+	t.Parallel()
+
+	all := []api.ContainerProcSummary{}
+
+	for _, tc := range topTestCases {
+		summary := api.ContainerProcSummary{
+			Name:      "not used",
+			Titles:    tc.titles,
+			Processes: tc.procs,
+			Service:   tc.name,
+			Replica:   "1",
+		}
+		all = append(all, summary)
+
+		t.Run(tc.name, func(t *testing.T) {
+			header, entries := collectTop([]api.ContainerProcSummary{summary})
+			assert.EqualValues(t, tc.header, header)
+			assert.EqualValues(t, tc.entries, entries)
+
+			var buf bytes.Buffer
+			err := topPrint(&buf, header, entries)
+
+			require.NoError(t, err)
+			assert.Equal(t, tc.output, buf.String())
+		})
+	}
+
+	t.Run("all", func(t *testing.T) {
+		header, entries := collectTop(all)
+		assert.EqualValues(t, topHeader{
+			"SERVICE": 0,
+			"#":       1,
+			"UID":     2,
+			"PID":     3,
+			"PPID":    4,
+			"C":       5,
+			"STIME":   6,
+			"TTY":     7,
+			"TIME":    8,
+			"GID":     9,
+			"CMD":     10,
+		}, header)
+		assert.EqualValues(t, []topEntries{
+			{
+				"SERVICE": "simple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:01",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "noppid",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:02",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "extra-hdr",
+				"#":       "1",
+				"UID":     "root",
+				"GID":     "1",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:03",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "multiple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:04",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "multiple",
+				"#":       "1",
+				"UID":     "root",
+				"PID":     "123",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:42",
+				"CMD":     "sleep infinity",
+			},
+		}, entries)
+
+		var buf bytes.Buffer
+		err := topPrint(&buf, header, entries)
+		require.NoError(t, err)
+		assert.Equal(t, trim(`
+			SERVICE    #   UID   PID  PPID  C   STIME  TTY  TIME      GID  CMD
+			simple     1   root  1    1     0   12:00  ?    00:00:01  -    /entrypoint
+			noppid     1   root  1    -     0   12:00  ?    00:00:02  -    /entrypoint
+			extra-hdr  1   root  1    1     0   12:00  ?    00:00:03  1    /entrypoint
+			multiple   1   root  1    1     0   12:00  ?    00:00:04  -    /entrypoint
+			multiple   1   root  123  1     0   12:00  ?    00:00:42  -    sleep infinity
+		`), buf.String())
+	})
+}
+
+func trim(s string) string {
+	var out bytes.Buffer
+	for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
+		out.WriteString(strings.TrimSpace(line))
+		out.WriteRune('\n')
+	}
+	return out.String()
+}