cmd/image/tree: refactor

Signed-off-by: Laura Brehm <laurabrehm@hey.com>
This commit is contained in:
Laura Brehm 2024-10-24 13:12:25 +01:00
parent da9e984231
commit 6c86dcc219
No known key found for this signature in database
GPG Key ID: 08EC1B0491948487
2 changed files with 328 additions and 275 deletions

View File

@ -4,12 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strings"
"unicode/utf8"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
imagetypes "github.com/docker/docker/api/types/image" imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
@ -22,13 +19,6 @@ type treeOptions struct {
filters filters.Args filters filters.Args
} }
type treeView struct {
images []topImage
// imageSpacing indicates whether there should be extra spacing between images.
imageSpacing bool
}
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error { func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{ images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{
All: opts.all, All: opts.all,
@ -39,28 +29,73 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
return err return err
} }
view := treeView{ warningColor := aec.LightYellowF
images: make([]topImage, 0, len(images)), if !dockerCLI.Out().IsTerminal() {
warningColor = noColor{}
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
_, _ = fmt.Fprintln(dockerCLI.Out(), "")
out := dockerCLI.Out()
_, width := out.GetTtySize()
if width == 0 {
width = 80
}
if width < 20 {
width = 20
}
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
topNameColor := aec.NewBuilder(aec.BlueF, 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{}
untaggedColor = noColor{}
}
columns := buildTableColumns(int(width), greenColor)
imageRows, anyImageHasChildren := buildTableRows(images)
columns = formatColumnsForOutput(int(width), columns, imageRows)
table := imageTreeTable{
columns: columns,
headerColor: headerColor,
indexNameColor: topNameColor,
untaggedColor: untaggedColor,
normalColor: normalColor,
spacing: anyImageHasChildren,
}
table.printTable(out, imageRows)
return nil
}
func buildTableRows(images []imagetypes.Summary) ([]ImageIndexRow, bool) {
imageRows := make([]ImageIndexRow, 0, len(images))
var hasChildren bool
for _, img := range images { for _, img := range images {
details := imageDetails{ details := rowDetails{
ID: img.ID, ID: img.ID,
DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
InUse: img.Containers > 0, InUse: img.Containers > 0,
} }
var totalContent int64 var totalContent int64
children := make([]subImage, 0, len(img.Manifests)) children := make([]ImageManifestRow, 0, len(img.Manifests))
for _, im := range img.Manifests { for _, im := range img.Manifests {
if im.Kind != imagetypes.ManifestKindImage { if im.Kind != imagetypes.ManifestKindImage {
continue continue
} }
im := im im := im
sub := subImage{ sub := ImageManifestRow{
Platform: platforms.Format(im.ImageData.Platform), Platform: platforms.Format(im.ImageData.Platform),
Available: im.Available, Available: im.Available,
Details: imageDetails{ Details: rowDetails{
ID: im.ID, ID: im.ID,
DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3),
InUse: len(im.ImageData.Containers) > 0, InUse: len(im.ImageData.Containers) > 0,
@ -77,12 +112,12 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
children = append(children, sub) children = append(children, sub)
// Add extra spacing between images if there's at least one entry with children. // Add extra spacing between images if there's at least one entry with children.
view.imageSpacing = true hasChildren = true
} }
details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
view.images = append(view.images, topImage{ imageRows = append(imageRows, ImageIndexRow{
Names: img.RepoTags, Names: img.RepoTags,
Details: details, Details: details,
Children: children, Children: children,
@ -90,64 +125,14 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
}) })
} }
sort.Slice(view.images, func(i, j int) bool { sort.Slice(imageRows, func(i, j int) bool {
return view.images[i].created > view.images[j].created return imageRows[i].created > imageRows[j].created
}) })
return printImageTree(dockerCLI, view) return imageRows, hasChildren
} }
type imageDetails struct { func buildTableColumns(ttyWidth int, usedColumnColor aec.ANSI) []imgColumn {
ID string
DiskUsage string
InUse 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, view treeView) 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.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{ columns := []imgColumn{
{ {
Title: "Image", Title: "Image",
@ -158,7 +143,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
Title: "ID", Title: "ID",
Align: alignLeft, Align: alignLeft,
Width: 12, Width: 12,
DetailsValue: func(d *imageDetails) string { DetailsValue: func(d *rowDetails) string {
return stringid.TruncateID(d.ID) return stringid.TruncateID(d.ID)
}, },
}, },
@ -166,7 +151,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
Title: "Disk usage", Title: "Disk usage",
Align: alignRight, Align: alignRight,
Width: 10, Width: 10,
DetailsValue: func(d *imageDetails) string { DetailsValue: func(d *rowDetails) string {
return d.DiskUsage return d.DiskUsage
}, },
}, },
@ -174,7 +159,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
Title: "Content size", Title: "Content size",
Align: alignRight, Align: alignRight,
Width: 12, Width: 12,
DetailsValue: func(d *imageDetails) string { DetailsValue: func(d *rowDetails) string {
return d.ContentSize return d.ContentSize
}, },
}, },
@ -182,8 +167,8 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
Title: "In Use", Title: "In Use",
Align: alignCenter, Align: alignCenter,
Width: 6, Width: 6,
Color: &greenColor, Color: &usedColumnColor,
DetailsValue: func(d *imageDetails) string { DetailsValue: func(d *rowDetails) string {
if d.InUse { if d.InUse {
return "✔" return "✔"
} }
@ -192,202 +177,5 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
}, },
} }
nameWidth := int(width) return columns
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
}
images := view.images
// 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 {
printNames(out, columns, img, topNameColor, untaggedColor)
printDetails(out, columns, normalColor, img.Details)
if len(img.Children) > 0 || view.imageSpacing {
_, _ = fmt.Fprintln(out)
}
printChildren(out, columns, img, normalColor)
_, _ = fmt.Fprintln(out)
}
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))
}
}
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)
_, _ = fmt.Fprintln(out, "")
}
}
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, "<untagged>"))
}
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
} }

View File

@ -0,0 +1,265 @@
package image
import (
"fmt"
"io"
"strings"
"unicode/utf8"
"github.com/morikuni/aec"
)
type imageTreeTable struct {
columns []imgColumn
headerColor aec.ANSI
indexNameColor aec.ANSI
untaggedColor aec.ANSI
normalColor aec.ANSI
highlightColor aec.ANSI
spacing bool
}
type ImageIndexRow struct {
Names []string
Details rowDetails
Children []ImageManifestRow
created int64
}
type ImageManifestRow struct {
Platform string
Available bool
Highlight bool
Details rowDetails
}
// rowDetails is used by both ImageIndexRow and ImageManifestRow
type rowDetails struct {
ID string
DiskUsage string
InUse bool
ContentSize string
}
func (t *imageTreeTable) printTable(out io.Writer, imgs []ImageIndexRow) {
t.printHeaders(out)
for _, img := range imgs {
t.printIndex(out, img)
_, _ = fmt.Fprintln(out)
}
}
func (t *imageTreeTable) printHeaders(out io.Writer) {
for i, h := range t.columns {
if i > 0 {
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
}
_, _ = fmt.Fprint(out, h.Print(t.headerColor, strings.ToUpper(h.Title)))
}
_, _ = fmt.Fprintln(out)
}
func (t *imageTreeTable) printIndex(out io.Writer, img ImageIndexRow) {
// print the names for the index
printNames(out, t.columns, img, t.indexNameColor, t.untaggedColor)
// print the rest of the columns/details for the header
printDetails(out, t.columns, t.normalColor, img.Details)
// print the manifest rows, with their details
if len(img.Children) > 0 || t.spacing {
_, _ = fmt.Fprintln(out)
}
printChildren(out, t.columns, img, t.normalColor)
}
func printDetails(out io.Writer, headers []imgColumn, defaultColor aec.ANSI, details rowDetails) {
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))
}
}
func printChildren(out io.Writer, headers []imgColumn, img ImageIndexRow, normalColor aec.ANSI) {
for idx, sub := range img.Children {
clr := normalColor
if !sub.Available {
clr = normalColor.With(aec.Faint)
}
if sub.Highlight {
clr = normalColor.With(aec.Bold)
}
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)
_, _ = fmt.Fprintln(out)
}
}
func printNames(out io.Writer, headers []imgColumn, img ImageIndexRow, color, untaggedColor aec.ANSI) {
if len(img.Names) == 0 {
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
}
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(*rowDetails) string
Color *aec.ANSI
}
// formatColumnsForOutput resizes the table columns for the provided tty
// size. The first column is made as narrow as possible, and columns are
// removed from the table output if the tty is not wide enough to
// accomodate the entire table.
func formatColumnsForOutput(ttyWidth int, columns []imgColumn, images []ImageIndexRow) []imgColumn {
nameWidth := ttyWidth
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
return columns
}
const columnSpacing = 3
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 []ImageIndexRow) 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
}