Browse Source

condense output of `compose top`

This changes the output format of `compose top` and inlines the service
container name into the table.

Previously, `compose top` had printed something like:

  <service name>
  UID    PID   ...
  root   1     ...

Now, the output looks more like this:

  SERVICE   UID    PID   ...
  <name>    root   1     ...

Signed-off-by: Dominik Menke <[email protected]>
Dominik Menke 11 months ago
parent
commit
a766e1669a
2 changed files with 363 additions and 17 deletions
  1. 50 17
      cmd/compose/top.go
  2. 313 0
      cmd/compose/top_test.go

+ 50 - 17
cmd/compose/top.go

@@ -49,6 +49,9 @@ 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
+type 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 +66,60 @@ 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}
+
+	// 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)
-				}
-				_, _ = fmt.Fprintf(w, strings.Repeat("%s\t", len(info))+"\n", info...)
+		for _, proc := range container.Processes {
+			entry := topEntries{"SERVICE": container.Name}
 
+			for i, title := range container.Titles {
+				if _, exists := header[title]; !exists {
+					header[title] = len(header)
+				}
+				entry[title] = proc[i]
 			}
-			_, _ = fmt.Fprintln(w)
-		},
-			container.Titles...)
-		if err != nil {
-			return err
+
+			entries = append(entries, entry)
 		}
 	}
-	return nil
+	return header, entries
 }
 
-func psPrinter(out io.Writer, printer func(writer io.Writer), headers ...string) error {
+func topPrint(out io.Writer, headers topHeader, rows []topEntries) error {
+	if len(rows) == 0 {
+		return nil
+	}
+
 	w := tabwriter.NewWriter(out, 5, 1, 3, ' ', 0)
-	_, _ = fmt.Fprintln(w, strings.Join(headers, "\t"))
-	printer(w)
+
+	// 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()
 }

+ 313 - 0
cmd/compose/top_test.go

@@ -0,0 +1,313 @@
+/*
+   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},
+		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,
+			"UID":     1,
+			"PID":     2,
+			"PPID":    3,
+			"C":       4,
+			"STIME":   5,
+			"TTY":     6,
+			"TIME":    7,
+			"CMD":     8,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "simple",
+				"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    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,
+			"UID":     1,
+			"PID":     2,
+			"C":       3,
+			"STIME":   4,
+			"TTY":     5,
+			"TIME":    6,
+			"CMD":     7,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "noppid",
+				"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    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,
+			"UID":     1,
+			"GID":     2,
+			"PID":     3,
+			"PPID":    4,
+			"C":       5,
+			"STIME":   6,
+			"TTY":     7,
+			"TIME":    8,
+			"CMD":     9,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "extra-hdr",
+				"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   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,
+			"UID":     1,
+			"PID":     2,
+			"PPID":    3,
+			"C":       4,
+			"STIME":   5,
+			"TTY":     6,
+			"TIME":    7,
+			"CMD":     8,
+		},
+		entries: []topEntries{
+			{
+				"SERVICE": "multiple",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:04",
+				"CMD":     "/entrypoint",
+			},
+			{
+				"SERVICE": "multiple",
+				"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   root   1     1      0    12:00   ?     00:00:04   /entrypoint
+			multiple   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:      tc.name,
+			Titles:    tc.titles,
+			Processes: tc.procs,
+		}
+		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,
+			"UID":     1,
+			"PID":     2,
+			"PPID":    3,
+			"C":       4,
+			"STIME":   5,
+			"TTY":     6,
+			"TIME":    7,
+			"CMD":     8,
+			"GID":     9,
+		}, header)
+		assert.EqualValues(t, []topEntries{
+			{
+				"SERVICE": "simple",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:01",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "noppid",
+				"UID":     "root",
+				"PID":     "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:02",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "extra-hdr",
+				"UID":     "root",
+				"GID":     "1",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:03",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "multiple",
+				"UID":     "root",
+				"PID":     "1",
+				"PPID":    "1",
+				"C":       "0",
+				"STIME":   "12:00",
+				"TTY":     "?",
+				"TIME":    "00:00:04",
+				"CMD":     "/entrypoint",
+			}, {
+				"SERVICE": "multiple",
+				"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       CMD              GID
+			simple      root   1     1      0    12:00   ?     00:00:01   /entrypoint      -
+			noppid      root   1     -      0    12:00   ?     00:00:02   /entrypoint      -
+			extra-hdr   root   1     1      0    12:00   ?     00:00:03   /entrypoint      1
+			multiple    root   1     1      0    12:00   ?     00:00:04   /entrypoint      -
+			multiple    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()
+}