diff --git a/cli/command/image/list.go b/cli/command/image/list.go index a691efed453b..cfe73c5adeb3 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -2,6 +2,7 @@ package image import ( "context" + "errors" "fmt" "io" @@ -24,6 +25,7 @@ type imagesOptions struct { format string filter opts.FilterOpt calledAs string + tree bool } // NewImagesCommand creates a new `docker images` command @@ -59,6 +61,10 @@ func NewImagesCommand(dockerCLI command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") + flags.BoolVar(&options.tree, "tree", false, "List multi-platform images as a tree (EXPERIMENTAL)") + flags.SetAnnotation("tree", "version", []string{"1.47"}) + flags.SetAnnotation("tree", "experimentalCLI", nil) + return cmd } @@ -75,6 +81,26 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions filters.Add("reference", options.matchName) } + if options.tree { + if options.quiet { + return errors.New("--quiet is not yet supported with --tree") + } + if options.noTrunc { + return errors.New("--no-trunc is not yet supported with --tree") + } + if options.showDigests { + return errors.New("--show-digest is not yet supported with --tree") + } + if options.format != "" { + return errors.New("--format is not yet supported with --tree") + } + + return runTree(ctx, dockerCLI, treeOptions{ + all: options.all, + filters: filters, + }) + } + images, err := dockerCLI.Client().ImageList(ctx, image.ListOptions{ All: options.all, Filters: filters, diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go new file mode 100644 index 000000000000..fea7ceef3e25 --- /dev/null +++ b/cli/command/image/tree.go @@ -0,0 +1,376 @@ +package image + +import ( + "context" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/containerd/platforms" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/streams" + "github.com/docker/docker/api/types/filters" + imagetypes "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/go-units" + "github.com/morikuni/aec" +) + +type treeOptions struct { + all bool + filters filters.Args +} + +func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error { + images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{ + All: opts.all, + Filters: opts.filters, + Manifests: true, + }) + if err != nil { + return err + } + + view := make([]topImage, 0, len(images)) + for _, img := range images { + details := imageDetails{ + ID: img.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), + Used: img.Containers > 0, + } + + var totalContent int64 + children := make([]subImage, 0, len(img.Manifests)) + for _, im := range img.Manifests { + if im.Kind != imagetypes.ManifestKindImage { + continue + } + + im := im + sub := subImage{ + Platform: platforms.Format(im.ImageData.Platform), + Available: im.Available, + Details: imageDetails{ + ID: im.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), + Used: len(im.ImageData.Containers) > 0, + ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3), + }, + } + + if sub.Details.Used { + // Mark top-level parent image as used if any of its subimages are used. + details.Used = true + } + + totalContent += im.Size.Content + children = append(children, sub) + } + + details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) + + view = append(view, topImage{ + Names: img.RepoTags, + Details: details, + Children: children, + created: img.Created, + }) + } + + sort.Slice(view, func(i, j int) bool { + return view[i].created > view[j].created + }) + + return printImageTree(dockerCLI, view) +} + +type imageDetails struct { + ID string + DiskUsage string + Used bool + ContentSize string +} + +type topImage struct { + Names []string + Details imageDetails + Children []subImage + + created int64 +} + +type subImage struct { + Platform string + Available bool + Details imageDetails +} + +const columnSpacing = 3 + +func printImageTree(dockerCLI command.Cli, images []topImage) error { + out := dockerCLI.Out() + _, width := out.GetTtySize() + if width == 0 { + width = 80 + } + if width < 20 { + width = 20 + } + + warningColor := aec.LightYellowF + headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI + topNameColor := aec.NewBuilder(aec.BlueF, aec.Underline, aec.Bold).ANSI + normalColor := aec.NewBuilder(aec.DefaultF).ANSI + greenColor := aec.NewBuilder(aec.GreenF).ANSI + untaggedColor := aec.NewBuilder(aec.Faint).ANSI + if !out.IsTerminal() { + headerColor = noColor{} + topNameColor = noColor{} + normalColor = noColor{} + greenColor = noColor{} + warningColor = noColor{} + untaggedColor = noColor{} + } + + _, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) + _, _ = fmt.Fprintln(out, "") + + columns := []imgColumn{ + { + Title: "Image", + Align: alignLeft, + Width: 0, + }, + { + Title: "ID", + Align: alignLeft, + Width: 12, + DetailsValue: func(d *imageDetails) string { + return stringid.TruncateID(d.ID) + }, + }, + { + Title: "Disk usage", + Align: alignRight, + Width: 10, + DetailsValue: func(d *imageDetails) string { + return d.DiskUsage + }, + }, + { + Title: "Content size", + Align: alignRight, + Width: 12, + DetailsValue: func(d *imageDetails) string { + return d.ContentSize + }, + }, + { + Title: "Used", + Align: alignCenter, + Width: 4, + Color: &greenColor, + DetailsValue: func(d *imageDetails) string { + if d.Used { + return "✔" + } + return " " + }, + }, + } + + nameWidth := int(width) + for idx, h := range columns { + if h.Width == 0 { + continue + } + d := h.Width + if idx > 0 { + d += columnSpacing + } + // If the first column gets too short, remove remaining columns + if nameWidth-d < 12 { + columns = columns[:idx] + break + } + nameWidth -= d + } + + // Try to make the first column as narrow as possible + widest := widestFirstColumnValue(columns, images) + if nameWidth > widest { + nameWidth = widest + } + columns[0].Width = nameWidth + + // Print columns + for i, h := range columns { + if i > 0 { + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + } + + _, _ = fmt.Fprint(out, h.Print(headerColor, strings.ToUpper(h.Title))) + } + + _, _ = fmt.Fprintln(out) + + // Print images + for _, img := range images { + _, _ = fmt.Fprintln(out, "") + printNames(out, columns, img, topNameColor, untaggedColor) + printDetails(out, columns, normalColor, img.Details) + printChildren(out, columns, img, normalColor) + } + + return nil +} + +func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) { + for _, h := range headers { + if h.DetailsValue == nil { + continue + } + + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + clr := defaultColor + if h.Color != nil { + clr = *h.Color + } + val := h.DetailsValue(&details) + _, _ = fmt.Fprint(out, h.Print(clr, val)) + } + fmt.Printf("\n") +} + +func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) { + for idx, sub := range img.Children { + clr := normalColor + if !sub.Available { + clr = normalColor.With(aec.Faint) + } + + if idx != len(img.Children)-1 { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) + } else { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) + } + + printDetails(out, headers, clr, sub.Details) + } +} + +func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) { + if len(img.Names) == 0 { + _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) + } + + for nameIdx, name := range img.Names { + if nameIdx != 0 { + _, _ = fmt.Fprintln(out, "") + } + _, _ = fmt.Fprint(out, headers[0].Print(color, name)) + } +} + +type alignment int + +const ( + alignLeft alignment = iota + alignCenter + alignRight +) + +type imgColumn struct { + Title string + Width int + Align alignment + + DetailsValue func(*imageDetails) string + Color *aec.ANSI +} + +func truncateRunes(s string, length int) string { + runes := []rune(s) + if len(runes) > length { + return string(runes[:length-3]) + "..." + } + return s +} + +func (h imgColumn) Print(clr aec.ANSI, s string) string { + switch h.Align { + case alignCenter: + return h.PrintC(clr, s) + case alignRight: + return h.PrintR(clr, s) + case alignLeft: + } + return h.PrintL(clr, s) +} + +func (h imgColumn) PrintC(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + fill := h.Width - ln + + l := fill / 2 + r := fill - l + + return strings.Repeat(" ", l) + clr.Apply(s) + strings.Repeat(" ", r) +} + +func (h imgColumn) PrintL(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + return clr.Apply(s) + strings.Repeat(" ", h.Width-ln) +} + +func (h imgColumn) PrintR(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + return strings.Repeat(" ", h.Width-ln) + clr.Apply(s) +} + +type noColor struct{} + +func (a noColor) With(_ ...aec.ANSI) aec.ANSI { + return a +} + +func (a noColor) Apply(s string) string { + return s +} + +func (a noColor) String() string { + return "" +} + +// widestFirstColumnValue calculates the width needed to fully display the image names and platforms. +func widestFirstColumnValue(headers []imgColumn, images []topImage) int { + width := len(headers[0].Title) + for _, img := range images { + for _, name := range img.Names { + if len(name) > width { + width = len(name) + } + } + for _, sub := range img.Children { + pl := len(sub.Platform) + len("└─ ") + if pl > width { + width = pl + } + } + } + return width +} diff --git a/docs/reference/commandline/image_ls.md b/docs/reference/commandline/image_ls.md index 3365c29c6b45..29174f171b46 100644 --- a/docs/reference/commandline/image_ls.md +++ b/docs/reference/commandline/image_ls.md @@ -17,6 +17,7 @@ List images | [`--format`](#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/go/formatting/ for more information about formatting output with templates | | [`--no-trunc`](#no-trunc) | `bool` | | Don't truncate output | | `-q`, `--quiet` | `bool` | | Only show image IDs | +| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) | diff --git a/docs/reference/commandline/images.md b/docs/reference/commandline/images.md index 1f7b3b5a4da0..8d615ec21b10 100644 --- a/docs/reference/commandline/images.md +++ b/docs/reference/commandline/images.md @@ -17,6 +17,7 @@ List images | `--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/go/formatting/ for more information about formatting output with templates | | `--no-trunc` | `bool` | | Don't truncate output | | `-q`, `--quiet` | `bool` | | Only show image IDs | +| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) |