From 5001d7bbd6f3a4a76711f14a069b13e1dd39e9e6 Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Wed, 12 Aug 2026 09:27:14 +0500 Subject: [PATCH] formatter: round human durations for CREATED columns CLI CREATED fields used go-units HumanDuration, which floors day/week values while Docker Desktop rounds. Localize a small HumanDuration with consistent round-half-up so image/container listings match Desktop more closely without vendoring frozen go-units changes. Fixes #6891 Signed-off-by: Dean Chen <862469039@qq.com> --- cli/command/config/formatter.go | 6 ++-- cli/command/formatter/buildcache.go | 5 +-- cli/command/formatter/container.go | 3 +- cli/command/formatter/image.go | 3 +- cli/command/image/formatter_history.go | 3 +- cli/command/secret/formatter.go | 6 ++-- cli/command/task/formatter.go | 4 +-- internal/duration/duration.go | 50 ++++++++++++++++++++++++++ internal/duration/duration_test.go | 21 +++++++++++ 9 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 internal/duration/duration.go create mode 100644 internal/duration/duration_test.go diff --git a/cli/command/config/formatter.go b/cli/command/config/formatter.go index 8e0b296df710..ba82c514854d 100644 --- a/cli/command/config/formatter.go +++ b/cli/command/config/formatter.go @@ -11,7 +11,7 @@ import ( "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/inspect" - "github.com/docker/go-units" + clduration "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" ) @@ -90,11 +90,11 @@ func (c *configContext) Name() string { } func (c *configContext) CreatedAt() string { - return units.HumanDuration(time.Now().UTC().Sub(c.c.Meta.CreatedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(c.c.Meta.CreatedAt)) + " ago" } func (c *configContext) UpdatedAt() string { - return units.HumanDuration(time.Now().UTC().Sub(c.c.Meta.UpdatedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(c.c.Meta.UpdatedAt)) + " ago" } func (c *configContext) Labels() string { diff --git a/cli/command/formatter/buildcache.go b/cli/command/formatter/buildcache.go index 3a3c349988ef..9abc7f8647f2 100644 --- a/cli/command/formatter/buildcache.go +++ b/cli/command/formatter/buildcache.go @@ -6,6 +6,7 @@ import ( "strings" "time" + clduration "github.com/docker/cli/internal/duration" "github.com/docker/go-units" "github.com/moby/moby/api/types/build" ) @@ -150,7 +151,7 @@ func (c *buildCacheContext) CreatedAt() string { } func (c *buildCacheContext) CreatedSince() string { - return units.HumanDuration(time.Now().UTC().Sub(c.v.CreatedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(c.v.CreatedAt)) + " ago" } func (c *buildCacheContext) LastUsedAt() string { @@ -164,7 +165,7 @@ func (c *buildCacheContext) LastUsedSince() string { if c.v.LastUsedAt == nil { return "" } - return units.HumanDuration(time.Now().UTC().Sub(*c.v.LastUsedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(*c.v.LastUsedAt)) + " ago" } func (c *buildCacheContext) UsageCount() string { diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index a282921a5110..6fe4c10fa5de 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -13,6 +13,7 @@ import ( "github.com/containerd/platforms" "github.com/distribution/reference" + clduration "github.com/docker/cli/internal/duration" "github.com/docker/go-units" "github.com/moby/moby/api/types/container" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -236,7 +237,7 @@ func (c *ContainerContext) CreatedAt() string { // by clock skew between the client and the daemon. func (c *ContainerContext) RunningFor() string { createdAt := time.Unix(c.c.Created, 0) - return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" } // Platform returns a human-readable representation of the container's diff --git a/cli/command/formatter/image.go b/cli/command/formatter/image.go index d24bf50947ce..0e2bf9a41110 100644 --- a/cli/command/formatter/image.go +++ b/cli/command/formatter/image.go @@ -5,6 +5,7 @@ import ( "time" "github.com/distribution/reference" + clduration "github.com/docker/cli/internal/duration" "github.com/docker/go-units" "github.com/moby/moby/api/types/image" ) @@ -238,7 +239,7 @@ func (c *imageContext) CreatedSince() string { return "" } - return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" } func (c *imageContext) CreatedAt() string { diff --git a/cli/command/image/formatter_history.go b/cli/command/image/formatter_history.go index 3a2cc51df57a..b0530890f222 100644 --- a/cli/command/image/formatter_history.go +++ b/cli/command/image/formatter_history.go @@ -6,6 +6,7 @@ import ( "time" "github.com/docker/cli/cli/command/formatter" + clduration "github.com/docker/cli/internal/duration" "github.com/docker/go-units" "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" @@ -96,7 +97,7 @@ func (c *historyContext) CreatedSince() string { if c.h.Created <= epoch { return "N/A" } - created := units.HumanDuration(time.Now().UTC().Sub(time.Unix(c.h.Created, 0))) + created := clduration.HumanDuration(time.Now().UTC().Sub(time.Unix(c.h.Created, 0))) return created + " ago" } diff --git a/cli/command/secret/formatter.go b/cli/command/secret/formatter.go index bc7129960f06..cc724354353f 100644 --- a/cli/command/secret/formatter.go +++ b/cli/command/secret/formatter.go @@ -11,7 +11,7 @@ import ( "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/inspect" - "github.com/docker/go-units" + clduration "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" ) @@ -90,7 +90,7 @@ func (c *secretContext) Name() string { } func (c *secretContext) CreatedAt() string { - return units.HumanDuration(time.Now().UTC().Sub(c.s.Meta.CreatedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(c.s.Meta.CreatedAt)) + " ago" } func (c *secretContext) Driver() string { @@ -101,7 +101,7 @@ func (c *secretContext) Driver() string { } func (c *secretContext) UpdatedAt() string { - return units.HumanDuration(time.Now().UTC().Sub(c.s.Meta.UpdatedAt)) + " ago" + return clduration.HumanDuration(time.Now().UTC().Sub(c.s.Meta.UpdatedAt)) + " ago" } func (c *secretContext) Labels() string { diff --git a/cli/command/task/formatter.go b/cli/command/task/formatter.go index cbe8c00d8dc4..be8e1abcb93f 100644 --- a/cli/command/task/formatter.go +++ b/cli/command/task/formatter.go @@ -7,7 +7,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/command/formatter" - "github.com/docker/go-units" + clduration "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" ) @@ -121,7 +121,7 @@ func (c *taskContext) DesiredState() string { func (c *taskContext) CurrentState() string { return fmt.Sprintf("%s %s ago", formatter.PrettyPrint(c.task.Status.State), - strings.ToLower(units.HumanDuration(time.Since(c.task.Status.Timestamp))), + strings.ToLower(clduration.HumanDuration(time.Since(c.task.Status.Timestamp))), ) } diff --git a/internal/duration/duration.go b/internal/duration/duration.go new file mode 100644 index 000000000000..15f0eda496b1 --- /dev/null +++ b/internal/duration/duration.go @@ -0,0 +1,50 @@ +// Package duration provides human-readable duration formatting for the CLI. +package duration + +import ( + "fmt" + "time" +) + +const ( + day = 24 * time.Hour + week = 7 * day + month = 30 * day + year = 365 * day +) + +// HumanDuration returns a human-readable approximation of a duration +// (e.g. "About a minute", "4 hours ago" style inputs without the "ago"). +// Day/week/month/year boundaries use round-half-up so CLI CREATED columns +// stay closer to Docker Desktop. +func HumanDuration(d time.Duration) string { + if d < 0 { + d = -d + } + if seconds := int(d.Seconds()); seconds < 1 { + return "Less than a second" + } else if seconds == 1 { + return "1 second" + } else if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } else if minutes := int(d.Minutes()); minutes == 1 { + return "About a minute" + } else if minutes < 60 { + return fmt.Sprintf("%d minutes", minutes) + } else if hours := roundDuration(d, time.Hour); hours == 1 { + return "About an hour" + } else if d < 48*time.Hour { + return fmt.Sprintf("%d hours", hours) + } else if d < 2*week { + return fmt.Sprintf("%d days", roundDuration(d, day)) + } else if d < 2*month { + return fmt.Sprintf("%d weeks", roundDuration(d, week)) + } else if d < 2*year { + return fmt.Sprintf("%d months", roundDuration(d, month)) + } + return fmt.Sprintf("%d years", roundDuration(d, year)) +} + +func roundDuration(d, unit time.Duration) int { + return int((d + unit/2) / unit) +} diff --git a/internal/duration/duration_test.go b/internal/duration/duration_test.go new file mode 100644 index 000000000000..eb28709d449e --- /dev/null +++ b/internal/duration/duration_test.go @@ -0,0 +1,21 @@ +package duration + +import ( + "testing" + "time" +) + +func TestHumanDurationRounding(t *testing.T) { + // 3.958 days should round to 4 days (Desktop-style), not floor to 3. + d := time.Duration(3.958333 * float64(24*time.Hour)) + got := HumanDuration(d) + if got != "4 days" { + t.Fatalf("got %q, want 4 days", got) + } + // 2 weeks + 4 days (~18 days) closer to 3 weeks than 2. + d = 2*week + 4*day + got = HumanDuration(d) + if got != "3 weeks" { + t.Fatalf("got %q, want 3 weeks", got) + } +}