diff --git a/cli/command/config/formatter.go b/cli/command/config/formatter.go index 8e0b296df710..0c3c0efc7365 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" + "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 duration.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 duration.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..e1637080ba4b 100644 --- a/cli/command/formatter/buildcache.go +++ b/cli/command/formatter/buildcache.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/docker/go-units" + "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/build" ) @@ -150,7 +150,7 @@ func (c *buildCacheContext) CreatedAt() string { } func (c *buildCacheContext) CreatedSince() string { - return units.HumanDuration(time.Now().UTC().Sub(c.v.CreatedAt)) + " ago" + return duration.HumanDuration(time.Now().UTC().Sub(c.v.CreatedAt)) + " ago" } func (c *buildCacheContext) LastUsedAt() string { @@ -164,7 +164,7 @@ func (c *buildCacheContext) LastUsedSince() string { if c.v.LastUsedAt == nil { return "" } - return units.HumanDuration(time.Now().UTC().Sub(*c.v.LastUsedAt)) + " ago" + return duration.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 e17313a6dc12..20a700038743 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -14,6 +14,7 @@ import ( "github.com/containerd/platforms" "github.com/distribution/reference" "github.com/docker/go-units" + duration "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/container" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) @@ -234,7 +235,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 duration.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..1d620e344711 100644 --- a/cli/command/formatter/image.go +++ b/cli/command/formatter/image.go @@ -6,6 +6,7 @@ import ( "github.com/distribution/reference" "github.com/docker/go-units" + duration "github.com/docker/cli/internal/duration" "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 duration.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..85163b80a1a7 100644 --- a/cli/command/image/formatter_history.go +++ b/cli/command/image/formatter_history.go @@ -6,7 +6,7 @@ import ( "time" "github.com/docker/cli/cli/command/formatter" - "github.com/docker/go-units" + "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" ) @@ -96,7 +96,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 := duration.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..7c54c25dcd23 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" + "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 duration.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 duration.HumanDuration(time.Now().UTC().Sub(c.s.Meta.UpdatedAt)) + " ago" } func (c *secretContext) Labels() string { diff --git a/cli/command/service/formatter.go b/cli/command/service/formatter.go index 1ec5263debe8..648a2a281aa8 100644 --- a/cli/command/service/formatter.go +++ b/cli/command/service/formatter.go @@ -11,7 +11,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/inspect" - "github.com/docker/go-units" + "github.com/docker/cli/internal/duration" "github.com/fvbommel/sortorder" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" @@ -323,7 +323,7 @@ func (ctx *serviceInspectContext) HasUpdateStatusStarted() bool { } func (ctx *serviceInspectContext) UpdateStatusStarted() string { - return units.HumanDuration(time.Since(*ctx.Service.UpdateStatus.StartedAt)) + " ago" + return duration.HumanDuration(time.Since(*ctx.Service.UpdateStatus.StartedAt)) + " ago" } func (ctx *serviceInspectContext) UpdateIsCompleted() bool { @@ -331,7 +331,7 @@ func (ctx *serviceInspectContext) UpdateIsCompleted() bool { } func (ctx *serviceInspectContext) UpdateStatusCompleted() string { - return units.HumanDuration(time.Since(*ctx.Service.UpdateStatus.CompletedAt)) + " ago" + return duration.HumanDuration(time.Since(*ctx.Service.UpdateStatus.CompletedAt)) + " ago" } func (ctx *serviceInspectContext) UpdateStatusMessage() string { diff --git a/cli/command/system/info.go b/cli/command/system/info.go index 5ae650a17c6a..e3d733a6ea1e 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -21,6 +21,7 @@ import ( "github.com/docker/cli/internal/registry" "github.com/docker/cli/templates" "github.com/docker/go-units" + duration "github.com/docker/cli/internal/duration" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/api/types/system" "github.com/moby/moby/client" @@ -450,9 +451,9 @@ func printSwarmInfo(output io.Writer, info system.Info) { fprintln(output, " Heartbeat Tick:", info.Swarm.Cluster.Spec.Raft.HeartbeatTick) fprintln(output, " Election Tick:", info.Swarm.Cluster.Spec.Raft.ElectionTick) fprintln(output, " Dispatcher:") - fprintln(output, " Heartbeat Period:", units.HumanDuration(info.Swarm.Cluster.Spec.Dispatcher.HeartbeatPeriod)) + fprintln(output, " Heartbeat Period:", duration.HumanDuration(info.Swarm.Cluster.Spec.Dispatcher.HeartbeatPeriod)) fprintln(output, " CA Configuration:") - fprintln(output, " Expiry Duration:", units.HumanDuration(info.Swarm.Cluster.Spec.CAConfig.NodeCertExpiry)) + fprintln(output, " Expiry Duration:", duration.HumanDuration(info.Swarm.Cluster.Spec.CAConfig.NodeCertExpiry)) fprintln(output, " Force Rotate:", info.Swarm.Cluster.Spec.CAConfig.ForceRotate) if caCert := strings.TrimSpace(info.Swarm.Cluster.Spec.CAConfig.SigningCACert); caCert != "" { fprintf(output, " Signing CA Certificate: \n%s\n\n", caCert) diff --git a/cli/command/task/formatter.go b/cli/command/task/formatter.go index cbe8c00d8dc4..cfcbd3e75f63 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" + "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(duration.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..c51223143bab --- /dev/null +++ b/internal/duration/duration.go @@ -0,0 +1,55 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.25 + +package duration + +import ( + "fmt" + "time" +) + +const ( + day = 24 * time.Hour + week = 7 * day + month = 30 * day + year = 365 * day +) + +// roundDuration returns d divided by unit, rounded to nearest integer. +func roundDuration(d, unit time.Duration) int { + return int(float64(d)/float64(unit) + 0.5) +} + +// HumanDuration returns a human-readable approximation of a duration +// (e.g. "About a minute", "4 hours ago", etc.) with consistent rounding +// at all unit boundaries. +// +// This is a drop-in replacement for docker/go-units HumanDuration that +// rounds day/week/month/year transitions instead of flooring them, +// matching Docker Desktop CREATED output behavior. +// +// Fixes docker/cli#6891. +func HumanDuration(d time.Duration) string { + if seconds := int(d.Seconds()); seconds <= 0 { + return "Less than a second" + } else if seconds < 2 { + return "1 second" + } else if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } else if minutes := int(d.Seconds()) / 60; 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 hours < 48 { + 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)) +} diff --git a/internal/duration/duration_test.go b/internal/duration/duration_test.go new file mode 100644 index 000000000000..2e03d6af6857 --- /dev/null +++ b/internal/duration/duration_test.go @@ -0,0 +1,86 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.25 + +package duration + +import ( + "testing" + "time" +) + +func TestHumanDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + want string + }{ + // Sub-second and seconds + {name: "negative", duration: -1 * time.Second, want: "Less than a second"}, + {name: "zero", duration: 0, want: "Less than a second"}, + {name: "one second", duration: time.Second, want: "1 second"}, + {name: "30 seconds", duration: 30 * time.Second, want: "30 seconds"}, + + // Minutes + {name: "one minute", duration: time.Minute, want: "About a minute"}, + {name: "90 seconds", duration: 90 * time.Second, want: "About a minute"}, + {name: "5 minutes", duration: 5 * time.Minute, want: "5 minutes"}, + + // Hours (already rounded in original) + {name: "one hour", duration: time.Hour, want: "About an hour"}, + {name: "5 hours", duration: 5 * time.Hour, want: "5 hours"}, + {name: "47 hours", duration: 47 * time.Hour, want: "47 hours"}, + + // Days (threshold: 48h to <336h) — rounding FIX + {name: "2 days exact", duration: 2 * day, want: "2 days"}, + {name: "13.5 days rounds up to 14", duration: 324 * time.Hour, want: "14 days"}, + {name: "6.5 days rounds up to 7", duration: 156 * time.Hour, want: "7 days"}, + {name: "6.4 days rounds down to 6", duration: 154 * time.Hour, want: "6 days"}, + + // Weeks (threshold: 336h to <1440h) — rounding FIX + {name: "2 weeks exact", duration: 2 * week, want: "2 weeks"}, + {name: "2.6 weeks rounds up to 3", duration: 18*day + 6*time.Hour, want: "3 weeks"}, + {name: "2.4 weeks rounds down to 2", duration: 17*day - 6*time.Hour, want: "2 weeks"}, + + // Months (threshold: 1440h to <17520h) — rounding FIX + {name: "2 months exact", duration: 2 * month, want: "2 months"}, + {name: "2.6 months rounds up to 3", duration: 78 * day, want: "3 months"}, + {name: "2.4 months rounds down to 2", duration: 72 * day, want: "2 months"}, + + // Years (threshold: >=17520h) — rounding FIX + {name: "2 years exact", duration: 2 * year, want: "2 years"}, + {name: "2.6 years rounds up to 3", duration: 949 * day, want: "3 years"}, + {name: "2.4 years rounds down to 2", duration: 876 * day, want: "2 years"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HumanDuration(tt.duration) + if got != tt.want { + t.Errorf("HumanDuration(%v) = %q, want %q", tt.duration, got, tt.want) + } + }) + } +} + +func TestRoundDuration(t *testing.T) { + tests := []struct { + name string + d time.Duration + unit time.Duration + want int + }{ + {name: "exact", d: 2 * day, unit: day, want: 2}, + {name: "round up", d: 36 * time.Hour, unit: day, want: 2}, + {name: "round down", d: 30 * time.Hour, unit: day, want: 1}, + {name: "half up", d: 12 * time.Hour, unit: day, want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundDuration(tt.d, tt.unit) + if got != tt.want { + t.Errorf("roundDuration(%v, %v) = %d, want %d", tt.d, tt.unit, got, tt.want) + } + }) + } +}