mirror of
https://github.com/psviderski/uncloud.git
synced 2026-10-07 05:39:01 +00:00
feat(logs): add support for days unit (d) in --since/until filters for logs
This commit is contained in:
1 parent
409dc3bfe3
commit
5e26cfbead
5 files changed
+145
-9
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration is an extended standard time.Duration that also supports days as a unit.
|
||||
type Duration = time.Duration
|
||||
|
||||
// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours.
|
||||
// Days can be fractional or combined with other units, such as "1.5d" or "2d3h".
|
||||
func ParseDuration(value string) (Duration, error) {
|
||||
if !strings.Contains(value, "d") {
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
|
||||
rest := value
|
||||
var normalised strings.Builder
|
||||
if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') {
|
||||
normalised.WriteByte(rest[0])
|
||||
rest = rest[1:]
|
||||
}
|
||||
for len(rest) > 0 {
|
||||
// Each component is a decimal number followed by a unit. Only the leading sign is allowed.
|
||||
numberEnd := 0
|
||||
for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') {
|
||||
numberEnd++
|
||||
}
|
||||
unitEnd := numberEnd
|
||||
for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' {
|
||||
unitEnd++
|
||||
}
|
||||
if numberEnd == 0 || unitEnd == numberEnd {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||
}
|
||||
number, unit := rest[:numberEnd], rest[numberEnd:unitEnd]
|
||||
if unit == "d" {
|
||||
// Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision.
|
||||
days, ok := new(big.Rat).SetString(number)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||
}
|
||||
days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour)))
|
||||
nanoseconds := new(big.Int).Quo(days.Num(), days.Denom())
|
||||
normalised.WriteString(nanoseconds.String())
|
||||
normalised.WriteString("ns")
|
||||
} else {
|
||||
normalised.WriteString(rest[:unitEnd])
|
||||
}
|
||||
rest = rest[unitEnd:]
|
||||
}
|
||||
|
||||
// The standard parser validates the remaining units and checks the total for overflow.
|
||||
duration, err := time.ParseDuration(normalised.String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
}{
|
||||
{"0", 0},
|
||||
{"2m30s", 150 * time.Second},
|
||||
{"1µs", time.Microsecond},
|
||||
{"1μs", time.Microsecond},
|
||||
{"2d", 48 * time.Hour},
|
||||
{"010d", 240 * time.Hour},
|
||||
{"08d", 192 * time.Hour},
|
||||
{"2d3h", 51 * time.Hour},
|
||||
{"3h2d", 51 * time.Hour},
|
||||
{"1d1d", 48 * time.Hour},
|
||||
{"1.5d", 36 * time.Hour},
|
||||
{".5d", 12 * time.Hour},
|
||||
{"1.d", 24 * time.Hour},
|
||||
{"-2d3h", -51 * time.Hour},
|
||||
{"+2d", 48 * time.Hour},
|
||||
{"0d", 0},
|
||||
{"0d1ns", time.Nanosecond},
|
||||
{"0.000000000001d", 86 * time.Nanosecond},
|
||||
{"-0.000000000001d", -86 * time.Nanosecond},
|
||||
{"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)},
|
||||
{"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
actual, err := ParseDuration(tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w",
|
||||
"1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s",
|
||||
"-106751d23h47m16.854775809s", "999999999999999999999999999d",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := ParseDuration(input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,10 @@ func Flags(options *Options) *pflag.FlagSet {
|
||||
set.StringVar(&options.Since, "since", "",
|
||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"Examples:\n"+
|
||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||
" --since 1h Relative duration (1 hour ago)\n"+
|
||||
" --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
|
||||
" Supported units: d (day = 24h), h (hour), m (minute),\n"+
|
||||
" s (second), ms (millisecond),\n"+
|
||||
" us/µs (microsecond), ns (nanosecond)\n"+
|
||||
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
|
||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
|
||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||
|
||||
@@ -28,7 +28,7 @@ func timestamp(value string, now time.Time) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
// A bare zero is the Unix epoch, matching Docker's log filters.
|
||||
if duration, err := time.ParseDuration(value); value != "0" && err == nil {
|
||||
if duration, err := ParseDuration(value); value != "0" && err == nil {
|
||||
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ func TestTimeRange(t *testing.T) {
|
||||
|
||||
location, err := time.LoadLocation("Australia/Sydney")
|
||||
require.NoError(t, err)
|
||||
// January is daylight-saving time, but July timestamps must use the winter offset.
|
||||
now := time.Date(2026, 1, 15, 12, 0, 0, 123456789, location)
|
||||
// October is daylight-saving time, but July timestamps must use the winter offset.
|
||||
// Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours.
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
@@ -35,8 +36,13 @@ func TestTimeRange(t *testing.T) {
|
||||
{"1763953966", "2025-11-24T03:12:46Z"},
|
||||
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
|
||||
{"0", "1970-01-01T00:00:00Z"},
|
||||
{"2m30s", "2026-01-15T00:57:30.123456789Z"},
|
||||
{"-1h", "2026-01-15T02:00:00.123456789Z"},
|
||||
{"2m30s", "2026-10-05T00:57:30.123456789Z"},
|
||||
{"1d", "2026-10-04T01:00:00.123456789Z"},
|
||||
{"2d", "2026-10-03T01:00:00.123456789Z"},
|
||||
{"2d3h", "2026-10-02T22:00:00.123456789Z"},
|
||||
{"1.5d", "2026-10-03T13:00:00.123456789Z"},
|
||||
{"-2d", "2026-10-07T01:00:00.123456789Z"},
|
||||
{"-1h", "2026-10-05T02:00:00.123456789Z"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
@@ -60,8 +66,8 @@ func TestTimeRange(t *testing.T) {
|
||||
|
||||
since, until, err := TimeRange("3h", "1h30m", now)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2026-01-14T22:00:00.123456789Z", since)
|
||||
assert.Equal(t, "2026-01-14T23:30:00.123456789Z", until)
|
||||
assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since)
|
||||
assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until)
|
||||
}
|
||||
|
||||
func TestTimeRange_Invalid(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user