diff --git a/README.md b/README.md index 51dac1c99..5d3446a88 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ Here are some examples of conditions you can use: | `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` | | `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` | | `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` | +| `age([BODY].updated) < 15m` | JSONPath `$.updated` is timestamp, date or duration | `{"updated":"10m"}` | `{}` | | `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` | | `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 | | `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... | @@ -509,6 +510,7 @@ Here are some examples of conditions you can use: |:---------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------| | `len` | If the given path leads to an array, returns its length. Otherwise, the JSON at the given path is minified and converted to a string, and the resulting number of characters is returned. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` | | `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` | +| `age` | If the given path or placeholder resolves to a supported timestamp format (e.g. like those in the [time](https://pkg.go.dev/time#pkg-constants) package), the age of the timestamp is returned as elapsed time since now (can be negative when in the future). | `age([BODY].lastUpdated) < 15m` | | `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` | | `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` | diff --git a/config/endpoint/dateparser.go b/config/endpoint/dateparser.go new file mode 100644 index 000000000..854bac1dc --- /dev/null +++ b/config/endpoint/dateparser.go @@ -0,0 +1,127 @@ +package endpoint + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +type DateFormatSpec struct { + provider func(value string, matches []int) (layout string, start, end int) + re *regexp.Regexp +} + +// ParseDate parses the given value as a date using a list of formats, and returns the timestamp or an error if parsing fails. +func ParseDate(value string) (ts time.Time, err error) { + if len(value) > 128 { + value = value[:128] // limit to first 128 characters to avoid performance issues with regexes + } + for _, format := range __formats { + if matches := format.re.FindStringSubmatchIndex(value); matches != nil { + layout, start, end := format.provider(value, matches) + ts, err = time.ParseInLocation(layout, value[start:end], time.Local) + if ts.Year() == 0 { + now := time.Now() + ts = ts.AddDate(now.Year(), int(now.Month()-1), now.Day()-1) + } + return + } + } + return time.Time{}, fmt.Errorf(`failed to parse "%s": unknown format`, value) +} + +var __formats = make([]DateFormatSpec, 0) + +// RegisterDateFormat allows registering a custom date format with a regex and a provider function that extracts the layout and timestamp substring from the input value. +func RegisterDateFormat(regex string, provider func(value string, matches []int) (layout string, start, end int)) { + __formats = append(__formats, DateFormatSpec{ + provider: provider, + re: regexp.MustCompile(regex), + }) +} + +func init() { + simple := func(format string) func(value string, matches []int) (layout string, start, end int) { + return func(value string, matches []int) (string, int, int) { + if len(matches) >= 4 && matches[2] > -1 && matches[3] > -1 { + return format, matches[2], matches[3] // first submatch group + } + return format, matches[0], matches[1] + } + } + + val := func(value string, match []int) string { + if match[0] == -1 || match[1] == -1 { + return "" + } + return value[match[0]:match[1]] + } + + // RFC3339/Nano / ISO 8601 formats - fast path for common formats (one regex for all) + RegisterDateFormat( + `\d{4}-\d{2}-\d{2}([Tt ])\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[\+\-]\d{2}:*\d{2})?`, + func(value string, matches []int) (layout string, start, end int) { + start, end = matches[0], matches[1] + s1 := val(value, matches[2:4]) + s3 := val(value, matches[6:8]) + if s1 == "T" || s1 == "t" { + if s3 == "Z" || s3 == "z" || strings.Contains(s3, ":") { + layout = time.RFC3339 + } else if s3 != "" { + layout = "2006-01-02T15:04:05Z0700" // ISO 8601 without colon in timezone + } else { + layout = "2006-01-02T15:04:05" // ISO 8601 without timezone + } + } else { + if s3 == "" { + layout = time.DateTime + } else if strings.Contains(s3, ":") { + layout = "2006-01-02 15:04:05Z07:00" // older format with colon in timezone + } else { + layout = "2006-01-02 15:04:05Z0700" // older format without colon in timezone + } + } + return + }) + + // other common formats + RegisterDateFormat(`(\d{2}/\d{2} \d{2}:\d{2}:\d{2}(AM|PM) '\d{2} [+-]\d{4})`, simple(time.Layout)) + RegisterDateFormat(`[A-Z][a-z]{2,10} [A-Z][a-z]{2,10}\s+\d{1,2} \d{2}:\d{2}:\d{2} \d{4}`, simple(time.ANSIC)) + RegisterDateFormat(`([A-Z][a-z]{2,10} [A-Z][a-z]{2,10}\s+\d{1,2} \d{2}:\d{2}:\d{2}(\.\d+)? [A-Z]{3} \d{4})`, simple(time.UnixDate)) + RegisterDateFormat(`([A-Z][a-z]{2,10} [A-Z][a-z]{2,10} \d{2} \d{2}:\d{2}:\d{2}(\.\d+)? [+-]\d{4} \d{4})`, simple(time.RubyDate)) + RegisterDateFormat(`(\d{2} [A-Z][a-z]{2,10} \d{2} \d{2}:\d{2}(\.\d+)? [A-Z]{3})`, simple(time.RFC822)) + RegisterDateFormat(`(\d{2} [A-Z][a-z]{2,10} \d{2} \d{2}:\d{2}(\.\d+)? [+-]\d{4})`, simple(time.RFC822Z)) + RegisterDateFormat(`([A-Z][a-z]{2,10}, \d{2}-[A-Z][a-z]{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)? [A-Z]{3})`, simple(time.RFC850)) + RegisterDateFormat(`([A-Z][a-z]{2,10}, \d{2} [A-Z][a-z]{2,10} \d{4} \d{2}:\d{2}:\d{2}(\.\d+)? [A-Z]{3})`, simple(time.RFC1123)) + RegisterDateFormat(`([A-Z][a-z]{2,10}, \d{2} [A-Z][a-z]{2,10} \d{4} \d{2}:\d{2}:\d{2}(\.\d+)? [+-]\d{4})`, simple(time.RFC1123Z)) + + // nginx format (common in access logs) + RegisterDateFormat( + `\d{2}/\w{3}/\d{4}([ :])\d{2}:\d{2}:\d{2}(\.\d+)?( [\+\-]?\d{4})?`, + func(value string, matches []int) (layout string, start, end int) { + start, end = matches[0], matches[1] + s1 := val(value, matches[2:4]) + s3 := val(value, matches[6:8]) + if s1 == ":" { + if s3 == "" { + layout = "02/Jan/2006:15:04:05" + } else { + layout = "02/Jan/2006:15:04:05 Z0700" + } + } else { + if s3 == "" { + layout = "02/Jan/2006 15:04:05" + } else { + layout = "02/Jan/2006 15:04:05 Z0700" + } + } + return + }) + RegisterDateFormat(`\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}`, simple("2006/01/02 15:04:05")) + + // partial date/time formats + RegisterDateFormat(`^[^0-9]*(\d{4}-\d{2}-\d{2})[^0-9]*$`, simple(time.DateOnly)) + RegisterDateFormat(`^[^0-9]*(\d{2}:\d{2}:\d{2})[^0-9]*$`, simple(time.TimeOnly)) + RegisterDateFormat(`^[^0-9]*(\d{1,2}:\d{2}(AM|PM))`, simple(time.Kitchen)) +} diff --git a/config/endpoint/placeholder.go b/config/endpoint/placeholder.go index 94ca221f5..07169eed1 100644 --- a/config/endpoint/placeholder.go +++ b/config/endpoint/placeholder.go @@ -2,8 +2,10 @@ package endpoint import ( "fmt" + "regexp" "strconv" "strings" + "time" "github.com/TwiN/gatus/v5/config/gontext" "github.com/TwiN/gatus/v5/jsonpath" @@ -66,6 +68,11 @@ const ( // Usage: has([BODY].errors) == true HasFunctionPrefix = "has(" + // AgeFunctionPrefix is the prefix for the age function + // + // Usage: age([BODY].timestamp) > 15m + AgeFunctionPrefix = "age(" + // PatternFunctionPrefix is the prefix for the pattern function // // Usage: [IP] == pat(192.168.*.*) @@ -96,6 +103,7 @@ const ( noFunction functionType = iota functionLen functionHas + functionAge ) // ResolvePlaceholder resolves all types of placeholders to their string values. @@ -115,6 +123,7 @@ const ( // Function wrappers: // - len(placeholder): Returns the length of the resolved value // - has(placeholder): Returns "true" if the placeholder exists and is non-empty, "false" otherwise +// - age(placeholder): Returns the age of the resolved value in milliseconds, or an error message if parsing fails // // Examples: // - ResolvePlaceholder("[STATUS]", result, nil) → "200" @@ -154,10 +163,6 @@ func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext case DomainExpirationPlaceholder: return formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil case BodyPlaceholder: - body := strings.TrimSpace(string(result.Body)) - if fn == functionHas { - return strconv.FormatBool(len(body) > 0), nil - } if fn == functionLen { // For len([BODY]), we need to check if it's JSON and get the actual length // Use jsonpath to evaluate the root element @@ -165,10 +170,8 @@ func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext if err == nil { return strconv.Itoa(resolvedLength), nil } - // Fall back to string length if not valid JSON - return strconv.Itoa(len(body)), nil } - return body, nil + return formatWithFunction(strings.TrimSpace(string(result.Body)), fn), nil } // Handle JSONPath expressions on BODY (including array indexing) @@ -200,6 +203,10 @@ func extractFunctionWrapper(placeholder string) (functionType, string) { inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix) return functionHas, inner } + if strings.HasPrefix(placeholder, AgeFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) { + inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, AgeFunctionPrefix), FunctionSuffix) + return functionAge, inner + } return noFunction, placeholder } @@ -222,9 +229,14 @@ func resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPla if err != nil { return originalPlaceholder + " " + InvalidConditionElementSuffix, nil } - if fn == functionLen { + + switch fn { + case functionLen: return strconv.Itoa(resolvedLength), nil + case functionAge: + return parseAge(resolvedValue), nil } + return resolvedValue, nil } @@ -245,7 +257,9 @@ func resolveContextPlaceholder(placeholder string, fn functionType, originalPlac if err != nil { return originalPlaceholder + " " + InvalidConditionElementSuffix, nil } - if fn == functionLen { + + switch fn { + case functionLen: switch v := value.(type) { case string: return strconv.Itoa(len(v)), nil @@ -256,7 +270,10 @@ func resolveContextPlaceholder(placeholder string, fn functionType, originalPlac default: return strconv.Itoa(len(fmt.Sprintf("%v", v))), nil } + case functionAge: + return parseAge(fmt.Sprintf("%v", value)), nil } + return fmt.Sprintf("%v", value), nil } @@ -267,7 +284,57 @@ func formatWithFunction(value string, fn functionType) string { return strconv.FormatBool(value != "") case functionLen: return strconv.Itoa(len(value)) + case functionAge: + return parseAge(value) default: return value } } + +var customFormat = regexp.MustCompile(`^\[\[(.+?)\]\][, ]+(.*)$`) // age([[layout]], timestamp-expression) + +// parseAge parses the timestamp from a selection of common formats and returns the age in milliseconds as a string, or an error message if parsing fails. +// Supported formats: +// - Custom layout: [[layout]], timestamp (e.g., age([[2006-01-02 15:04:05]], "2024-01-01 12:00:00")) +// - Unix timestamp in seconds or milliseconds (e.g., age("1700000000") or age("1700000000000")) +// - Duration (e.g., age("15m"), age("2h")) +// - Parsed date using registered formats (e.g., age("2024-01-01T12:00:00Z")) +func parseAge(timestamp string) string { + var parsed time.Time + var failed string + + timestamp = strings.TrimSpace(timestamp) + + if m := customFormat.FindStringSubmatch(timestamp[:min(len(timestamp), 256)]); m != nil { + // custom format: [[layout]], timestamp + if d, err := time.ParseInLocation(m[1], strings.TrimSpace(m[2]), time.Local); err == nil { + parsed = d + } else { + failed = fmt.Sprintf("failed to parse custom layout '%s': %v", m[1], err) + } + + } else if ts, err := strconv.ParseFloat(timestamp, 64); err == nil { + // unix timestamp in seconds or milliseconds, supports int, float, and scientific notation (e.g., 1.5e9 for 1500000000) + if ts < 1e10 { + ts = ts * 1000.0 // If it's in seconds, convert to milliseconds prior to converting to int64 + } + parsed = time.UnixMilli(int64(ts)) + + } else if dur, err := time.ParseDuration(timestamp); err == nil { + // duration (e.g., "15m", "2h") + parsed = time.Now().Add(-dur) + + } else { + // parsed date + if d, err := ParseDate(timestamp); err == nil { + parsed = d + } else { + failed = fmt.Sprintf("%v", err) + } + } + + if len(failed) > 0 { + return failed + } + return strconv.FormatInt(time.Since(parsed).Milliseconds(), 10) +} diff --git a/config/endpoint/placeholder_test.go b/config/endpoint/placeholder_test.go index 14ecfffb8..7d6f58497 100644 --- a/config/endpoint/placeholder_test.go +++ b/config/endpoint/placeholder_test.go @@ -1,6 +1,9 @@ package endpoint import ( + "fmt" + "strconv" + "strings" "testing" "time" @@ -16,7 +19,7 @@ func TestResolvePlaceholder(t *testing.T) { Connected: true, CertificateExpiration: 30 * 24 * time.Hour, DomainExpiration: 365 * 24 * time.Hour, - Body: []byte(`{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`), + Body: []byte(`{"status":"success", "ts":"2m","items":[1,2,3],"user":{"name":"john","id":123}}`), } ctx := gontext.New(map[string]interface{}{ @@ -26,6 +29,7 @@ func TestResolvePlaceholder(t *testing.T) { "nested": map[string]interface{}{ "value": "test", }, + "timestamp": "4m", }) tests := []struct { @@ -41,7 +45,7 @@ func TestResolvePlaceholder(t *testing.T) { {"connected", "[CONNECTED]", "true"}, {"certificate-expiration", "[CERTIFICATE_EXPIRATION]", "2592000000"}, {"domain-expiration", "[DOMAIN_EXPIRATION]", "31536000000"}, - {"body", "[BODY]", `{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`}, + {"body", "[BODY]", `{"status":"success", "ts":"2m","items":[1,2,3],"user":{"name":"john","id":123}}`}, // Case insensitive placeholders {"status-lowercase", "[status]", "200"}, @@ -52,6 +56,9 @@ func TestResolvePlaceholder(t *testing.T) { {"len-ip", "len([IP])", "9"}, {"has-status", "has([STATUS])", "true"}, {"has-empty", "has()", "false"}, + {"len-empty", "len()", "len() (INVALID)"}, + {"age-empty", "age()", "age() (INVALID)"}, + {"age-dns-rcode", "age([DNS_RCODE])", `failed to parse "NOERROR": unknown format`}, // JSONPath expressions {"body-status", "[BODY].status", "success"}, @@ -61,6 +68,8 @@ func TestResolvePlaceholder(t *testing.T) { {"body-array-index", "[BODY].items[0]", "1"}, {"has-body-status", "has([BODY].status)", "true"}, {"has-body-missing", "has([BODY].missing)", "false"}, + {"age-body-ts", "age([BODY].ts)", "age: 120000"}, + {"age-body-status", "age([BODY].status)", `failed to parse "success": unknown format`}, // Context placeholders {"context-user-id", "[CONTEXT].user_id", "abc123"}, @@ -69,6 +78,7 @@ func TestResolvePlaceholder(t *testing.T) { {"len-context-array", "len([CONTEXT].array_data)", "3"}, {"has-context-user-id", "has([CONTEXT].user_id)", "true"}, {"has-context-missing", "has([CONTEXT].missing)", "false"}, + {"age-context-timestamp", "age([CONTEXT].timestamp)", "age: 240000"}, // Invalid placeholders {"unknown-placeholder", "[UNKNOWN]", "[UNKNOWN]"}, @@ -87,7 +97,9 @@ func TestResolvePlaceholder(t *testing.T) { if err != nil { t.Errorf("unexpected error: %v", err) } - if actual != test.expected { + if strings.HasPrefix(test.expected, "age: ") { + assertAgeInRange(t, actual, test.expected) + } else if actual != test.expected { t.Errorf("expected '%s', got '%s'", test.expected, actual) } }) @@ -123,3 +135,113 @@ func TestResolvePlaceholderWithoutContext(t *testing.T) { }) } } + +func assertAgeInRange(t *testing.T, actual string, expected string) { + expectedAge, _ := strconv.Atoi(strings.TrimPrefix(expected, "age: ")) + actualAge, err := strconv.Atoi(actual) + if err != nil { + t.Errorf("expected an age in milliseconds, got '%s'", actual) + } else if actualAge < expectedAge-1000 || actualAge > expectedAge+1000 { + t.Errorf("expected age around %d ms, got %d ms", expectedAge, actualAge) + } +} + +func TestParseAge(t *testing.T) { + makeAge := func(duration time.Duration) string { + return fmt.Sprintf("age: %d", duration.Milliseconds()) + } + + now := time.Now() + + absoluteDate := time.Date(2024, 10, 12, 15, 13, 6, 0, time.UTC) + absoluteDateLocal := time.Date(2024, 10, 12, 15, 13, 6, 0, time.Local) + absoluteDateOffset := time.Date(2024, 10, 12, 15, 13, 6, 0, time.FixedZone("Offset", 4*60*60)) + + timeOnlyLocal := time.Date(now.Year(), now.Month(), now.Day(), 4, 13, 6, 0, time.Local) + + tests := []struct { + timestamp string + expected string + }{ + // RFC3339/Nano / ISO 8601 formats + {"2024-10-12T15:13:06Z", makeAge(time.Since(absoluteDate))}, + {" 2024-10-12T15:13:06Z", makeAge(time.Since(absoluteDate))}, + {"2024-10-12T15:13:06Z ", makeAge(time.Since(absoluteDate))}, + {" 2024-10-12T15:13:06Z ", makeAge(time.Since(absoluteDate))}, + {"2024-10-12T15:13:06", makeAge(time.Since(absoluteDateLocal))}, + {"2024-10-12T15:13:06+04:00", makeAge(time.Since(absoluteDateOffset))}, + {"2024-10-12T15:13:06+0400", makeAge(time.Since(absoluteDateOffset))}, + {"2024-10-12 15:13:06Z", makeAge(time.Since(absoluteDate))}, + {"2024-10-12 15:13:06", makeAge(time.Since(absoluteDateLocal))}, + {"2024-10-12 15:13:06+04:00", makeAge(time.Since(absoluteDateOffset))}, + {"2024-10-12 15:13:06+0400", makeAge(time.Since(absoluteDateOffset))}, + + // other formats + {"Sat Oct 12 15:13:06 2024", makeAge(time.Since(absoluteDateLocal))}, // ANSIC + {"Sat Oct 12 15:13:06 UTC 2024", makeAge(time.Since(absoluteDate))}, // UnixDate + {"Sat Oct 12 15:13:06 +0400 2024", makeAge(time.Since(absoluteDateOffset))}, // RubyDate + {"12 Oct 24 15:13 UTC", makeAge(time.Since(absoluteDate.Add(-6 * time.Second)))}, // RFC822 + {"12 Oct 24 15:13 +0400", makeAge(time.Since(absoluteDateOffset.Add(-6 * time.Second)))}, // RFC822Z + {"Saturday, 12-Oct-24 15:13:06 UTC", makeAge(time.Since(absoluteDate))}, // RFC850 + {"Sat, 12 Oct 2024 15:13:06.371213 UTC", makeAge(time.Since(absoluteDate))}, // RFC1123 + {"Sat, 12 Oct 2024 15:13:06.371213 +0400", makeAge(time.Since(absoluteDateOffset))}, // RFC1123Z + {"10/12 03:13:06PM '24 +0400", makeAge(time.Since(absoluteDateOffset))}, // Go reference time + + // common access log format + {"12/Oct/2024 15:13:06 +0400", makeAge(time.Since(absoluteDateOffset))}, + {"12/Oct/2024:15:13:06 +0000", makeAge(time.Since(absoluteDate))}, + {"12/Oct/2024 15:13:06", makeAge(time.Since(absoluteDateLocal))}, + {"12/Oct/2024:15:13:06", makeAge(time.Since(absoluteDateLocal))}, + {"2024/10/12 15:13:06", makeAge(time.Since(absoluteDateLocal))}, + + // finds timestamp in text (limited to first 128 characters) + {" -- 2024-10-12T15:13:06Z -- ", makeAge(time.Since(absoluteDate))}, + {fmt.Sprintf("%s 2024-10-12T15:13:06Z", strings.Repeat("-", 128)), fmt.Sprintf(`failed to parse "%s": unknown format`, strings.Repeat("-", 128))}, + + // custom format - currently not functional as age() call doesn't allow a parameter list. + {"[[2006|01|02 15.04.05]], 2024|10|12 15.13.06", makeAge(time.Since(absoluteDateLocal))}, + {"[[2006|01|02 15.04.05Z0700]], 2024|10|12 15.13.06Z", makeAge(time.Since(absoluteDate))}, + {"[[2006]], 2024|10|12 15.13.06Z", `failed to parse custom layout '2006': parsing time "2024|10|12 15.13.06Z": extra text: "|10|12 15.13.06Z"`}, + + // unix timestamp variants + {fmt.Sprintf("%d", absoluteDate.Unix()), makeAge(time.Since(absoluteDate))}, + {fmt.Sprintf("%d", absoluteDate.UnixMilli()), makeAge(time.Since(absoluteDate))}, + {fmt.Sprintf("%f", float64(absoluteDate.UnixMilli())/1000.0), makeAge(time.Since(absoluteDate))}, + // unix timestamp variants with local TZ + {fmt.Sprintf("%d", absoluteDateLocal.Unix()), makeAge(time.Since(absoluteDateLocal))}, + {fmt.Sprintf("%d", absoluteDateLocal.UnixMilli()), makeAge(time.Since(absoluteDateLocal))}, + {fmt.Sprintf("%f", float64(absoluteDateLocal.UnixMilli())/1000.0), makeAge(time.Since(absoluteDateLocal))}, + // scientific notation (when the timestamp is a JSON number, it becomes scientific notation after being stringified!) + {"1.771548534964e+12", makeAge(time.Since(time.UnixMilli(1771548627000)) + 92036*time.Millisecond)}, + {"1.771548534964e+9", makeAge(time.Since(time.Unix(1771548627, 0)) + 92036*time.Millisecond)}, + + // date only + {"2024-10-12", makeAge(time.Since(absoluteDateLocal.Add(-(15*time.Hour + 13*time.Minute + 6*time.Second))))}, + + // time only + {"04:13:06", makeAge(time.Since(timeOnlyLocal))}, + {"4:13AM", makeAge(time.Since(timeOnlyLocal.Add(-6 * time.Second)))}, + + // duration + {"15s", makeAge(15 * time.Second)}, + {"1m15s", makeAge(75 * time.Second)}, + + // future date (negative age) + {"-24h", makeAge(-1 * 24 * time.Hour)}, + {fmt.Sprintf("%d", time.Now().Add(3*24*time.Hour).UnixMilli()), makeAge(-1 * 3 * 24 * time.Hour)}, + + // error + {"this is not a date", `failed to parse "this is not a date": unknown format`}, + } + + for _, test := range tests { + t.Run(test.timestamp, func(t *testing.T) { + actual := parseAge(test.timestamp) + if strings.HasPrefix(test.expected, "age: ") { + assertAgeInRange(t, actual, test.expected) + } else if actual != test.expected { + t.Errorf("expected '%s', got '%s'", test.expected, actual) + } + }) + } +} diff --git a/go.mod b/go.mod index 620eda924..f5e1b1cc8 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.4.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -78,6 +79,7 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -93,8 +95,8 @@ require ( golang.org/x/image v0.35.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index b02503236..ca2b17ca8 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTel github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -135,8 +135,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -238,8 +238,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -259,8 +259,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=