diff --git a/.apm/instructions/backend.instructions.md b/.apm/instructions/backend.instructions.md index dc7ec63cee..47857a8af4 100644 --- a/.apm/instructions/backend.instructions.md +++ b/.apm/instructions/backend.instructions.md @@ -10,3 +10,9 @@ applyTo: "**/*.go" counts, a log.WithField() call is preferred over formatting values into a string. * After making changes, always run `gofmt -w` on modified files to ensure proper formatting. * When modifying any data provider (BigQuery or PostgreSQL), ensure **parity between both implementations**. Changes to query logic, filtering, or returned data in one provider must be reflected in the other. +* **Timestamps and dates**: Use proper types, never epoch integers. + - PostgreSQL columns: use `TIMESTAMP WITH TIME ZONE` for timestamps and `DATE` for date-only values. All timestamps are UTC. + - Go structs: use `time.Time` for timestamps. Use `civil.Date` (`cloud.google.com/go/civil`) for date-only values (e.g., GA dates, development start dates). Do not use `string` for date or timestamp fields; let JSON marshaling produce the correct format. + - API responses: timestamps serialize as RFC 3339 strings, dates as `YYYY-MM-DD` strings. Never return epoch millisecond integers. Never manually format with `time.Format()` into a string field. + - Materialized views: project timestamp columns directly from the source table. Do not convert to epoch with `EXTRACT(epoch FROM ...)`. + - GORM model tags: include `gorm:"type:date"` on date-only columns so GORM and migrations use the correct PostgreSQL type. diff --git a/.apm/instructions/frontend.instructions.md b/.apm/instructions/frontend.instructions.md index 72c368175e..332b8da9a2 100644 --- a/.apm/instructions/frontend.instructions.md +++ b/.apm/instructions/frontend.instructions.md @@ -14,3 +14,10 @@ npx prettier --write . * Keep UI elements consistent with Material-UI standards. The frontend uses `npm`. If you must install or update any dependencies, always use the `--ignore-scripts` flag. + +* **Timestamps and dates from the API**: + - Timestamps arrive as RFC 3339 strings (e.g., `"2024-06-27T15:30:00Z"`), not epoch millisecond integers. Use `new Date(value)` or `Temporal.Instant.from(value)` to parse them. + - Dates arrive as `YYYY-MM-DD` strings (e.g., `"2024-06-27"`). Use `Temporal.PlainDate.from(value)` for date arithmetic. + - For MUI DataGrid timestamp columns, use `type: 'date'` with a `valueGetter` that returns a `Date` object. Do not return epoch milliseconds from `valueGetter`. + - For filter values sent to the API, use ISO 8601 strings (e.g., `new Date(...).toISOString()`), not epoch millisecond integers. + - For day-level bucketing or date arithmetic, prefer `Temporal.PlainDate` over `Date` with manual millisecond math. diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 786ad79316..458d0e0ae9 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -10,3 +10,9 @@ paths: counts, a log.WithField() call is preferred over formatting values into a string. * After making changes, always run `gofmt -w` on modified files to ensure proper formatting. * When modifying any data provider (BigQuery or PostgreSQL), ensure **parity between both implementations**. Changes to query logic, filtering, or returned data in one provider must be reflected in the other. +* **Timestamps and dates**: Use proper types, never epoch integers. + - PostgreSQL columns: use `TIMESTAMP WITH TIME ZONE` for timestamps and `DATE` for date-only values. All timestamps are UTC. + - Go structs: use `time.Time` for timestamps. Use `civil.Date` (`cloud.google.com/go/civil`) for date-only values (e.g., GA dates, development start dates). Do not use `string` for date or timestamp fields; let JSON marshaling produce the correct format. + - API responses: timestamps serialize as RFC 3339 strings, dates as `YYYY-MM-DD` strings. Never return epoch millisecond integers. Never manually format with `time.Format()` into a string field. + - Materialized views: project timestamp columns directly from the source table. Do not convert to epoch with `EXTRACT(epoch FROM ...)`. + - GORM model tags: include `gorm:"type:date"` on date-only columns so GORM and migrations use the correct PostgreSQL type. diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 0551c30f22..0beefa4ba6 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -14,3 +14,10 @@ npx prettier --write . * Keep UI elements consistent with Material-UI standards. The frontend uses `npm`. If you must install or update any dependencies, always use the `--ignore-scripts` flag. + +* **Timestamps and dates from the API**: + - Timestamps arrive as RFC 3339 strings (e.g., `"2024-06-27T15:30:00Z"`), not epoch millisecond integers. Use `new Date(value)` or `Temporal.Instant.from(value)` to parse them. + - Dates arrive as `YYYY-MM-DD` strings (e.g., `"2024-06-27"`). Use `Temporal.PlainDate.from(value)` for date arithmetic. + - For MUI DataGrid timestamp columns, use `type: 'date'` with a `valueGetter` that returns a `Date` object. Do not return epoch milliseconds from `valueGetter`. + - For filter values sent to the API, use ISO 8601 strings (e.g., `new Date(...).toISOString()`), not epoch millisecond integers. + - For day-level bucketing or date arithmetic, prefer `Temporal.PlainDate` over `Date` with manual millisecond math. diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc index 95dd026526..8251549dad 100644 --- a/.cursor/rules/backend.mdc +++ b/.cursor/rules/backend.mdc @@ -10,3 +10,9 @@ globs: "**/*.go" counts, a log.WithField() call is preferred over formatting values into a string. * After making changes, always run `gofmt -w` on modified files to ensure proper formatting. * When modifying any data provider (BigQuery or PostgreSQL), ensure **parity between both implementations**. Changes to query logic, filtering, or returned data in one provider must be reflected in the other. +* **Timestamps and dates**: Use proper types, never epoch integers. + - PostgreSQL columns: use `TIMESTAMP WITH TIME ZONE` for timestamps and `DATE` for date-only values. All timestamps are UTC. + - Go structs: use `time.Time` for timestamps. Use `civil.Date` (`cloud.google.com/go/civil`) for date-only values (e.g., GA dates, development start dates). Do not use `string` for date or timestamp fields; let JSON marshaling produce the correct format. + - API responses: timestamps serialize as RFC 3339 strings, dates as `YYYY-MM-DD` strings. Never return epoch millisecond integers. Never manually format with `time.Format()` into a string field. + - Materialized views: project timestamp columns directly from the source table. Do not convert to epoch with `EXTRACT(epoch FROM ...)`. + - GORM model tags: include `gorm:"type:date"` on date-only columns so GORM and migrations use the correct PostgreSQL type. diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index fe7ff9c36e..f56ba9c834 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -14,3 +14,10 @@ npx prettier --write . * Keep UI elements consistent with Material-UI standards. The frontend uses `npm`. If you must install or update any dependencies, always use the `--ignore-scripts` flag. + +* **Timestamps and dates from the API**: + - Timestamps arrive as RFC 3339 strings (e.g., `"2024-06-27T15:30:00Z"`), not epoch millisecond integers. Use `new Date(value)` or `Temporal.Instant.from(value)` to parse them. + - Dates arrive as `YYYY-MM-DD` strings (e.g., `"2024-06-27"`). Use `Temporal.PlainDate.from(value)` for date arithmetic. + - For MUI DataGrid timestamp columns, use `type: 'date'` with a `valueGetter` that returns a `Date` object. Do not return epoch milliseconds from `valueGetter`. + - For filter values sent to the API, use ISO 8601 strings (e.g., `new Date(...).toISOString()`), not epoch millisecond integers. + - For day-level bucketing or date arithmetic, prefer `Temporal.PlainDate` over `Date` with manual millisecond math. diff --git a/AGENTS.md b/AGENTS.md index 102b8317ab..29e9a840f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - + @@ -83,6 +83,12 @@ Favor clarity and maintainability over cleverness. Comments should be minimal, h counts, a log.WithField() call is preferred over formatting values into a string. * After making changes, always run `gofmt -w` on modified files to ensure proper formatting. * When modifying any data provider (BigQuery or PostgreSQL), ensure **parity between both implementations**. Changes to query logic, filtering, or returned data in one provider must be reflected in the other. +* **Timestamps and dates**: Use proper types, never epoch integers. + - PostgreSQL columns: use `TIMESTAMP WITH TIME ZONE` for timestamps and `DATE` for date-only values. All timestamps are UTC. + - Go structs: use `time.Time` for timestamps. Use `civil.Date` (`cloud.google.com/go/civil`) for date-only values (e.g., GA dates, development start dates). Do not use `string` for date or timestamp fields; let JSON marshaling produce the correct format. + - API responses: timestamps serialize as RFC 3339 strings, dates as `YYYY-MM-DD` strings. Never return epoch millisecond integers. Never manually format with `time.Format()` into a string field. + - Materialized views: project timestamp columns directly from the source table. Do not convert to epoch with `EXTRACT(epoch FROM ...)`. + - GORM model tags: include `gorm:"type:date"` on date-only columns so GORM and migrations use the correct PostgreSQL type. ## Files matching `**/*_test.go` diff --git a/CLAUDE.md b/CLAUDE.md index 043f8939fa..288dcdd9a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md - + # Project Standards @@ -84,6 +84,12 @@ Favor clarity and maintainability over cleverness. Comments should be minimal, h counts, a log.WithField() call is preferred over formatting values into a string. * After making changes, always run `gofmt -w` on modified files to ensure proper formatting. * When modifying any data provider (BigQuery or PostgreSQL), ensure **parity between both implementations**. Changes to query logic, filtering, or returned data in one provider must be reflected in the other. +* **Timestamps and dates**: Use proper types, never epoch integers. + - PostgreSQL columns: use `TIMESTAMP WITH TIME ZONE` for timestamps and `DATE` for date-only values. All timestamps are UTC. + - Go structs: use `time.Time` for timestamps. Use `civil.Date` (`cloud.google.com/go/civil`) for date-only values (e.g., GA dates, development start dates). Do not use `string` for date or timestamp fields; let JSON marshaling produce the correct format. + - API responses: timestamps serialize as RFC 3339 strings, dates as `YYYY-MM-DD` strings. Never return epoch millisecond integers. Never manually format with `time.Format()` into a string field. + - Materialized views: project timestamp columns directly from the source table. Do not convert to epoch with `EXTRACT(epoch FROM ...)`. + - GORM model tags: include `gorm:"type:date"` on date-only columns so GORM and migrations use the correct PostgreSQL type. ## Files matching `**/*_test.go` diff --git a/apm.lock.yaml b/apm.lock.yaml index 53de8a9a9c..a180229dfd 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -104,10 +104,10 @@ local_deployed_file_hashes: .claude/commands/sippy-generate-release-views.md: sha256:eb4c9eeeea2ab2a90e8a8839147d8a1a309ea6ce3dafd397c6d2485c93068a9a .claude/commands/sippy-update-ga-release-views.md: sha256:4a5589bacc05127e427a2de4d34a8f13e05e297bdf6ebf7473c9e71f47a6b4f4 .claude/commands/sippy-update-job-variant.md: sha256:f88742dddeec5024931959a8330fdce362ffdd9b8825e808830ac346605cbd16 - .claude/rules/backend.md: sha256:b1bd6bacccc8bfc8ab2c060496fede3bdcece46b59cf218cd7cddc3617ead4c5 + .claude/rules/backend.md: sha256:7b954581d356fb4f0d4ff966d774739847c1cf5e0ec60c29cb7c730a1b3966f0 .claude/rules/config.md: sha256:96c5e42c039230f1e4e7f9ba56d04e6f76f23deeecdd858634c440d6029a6a83 .claude/rules/dev-commands.md: sha256:eb32bdc640add2044138807572ea1afb73ee1bba038f5098032a89987a7ddc04 - .claude/rules/frontend.md: sha256:ff22046c5b951769218bbdf36499e67c70896811b8ef161ca6d3729a3423997d + .claude/rules/frontend.md: sha256:cdfc2cdc3981c43d91dcf9583d0fff2d3f0b4f355ded4237aa264211fe42600c .claude/rules/general.md: sha256:997f68e86cb43485ec5f108be3417f9bbb43ae1faffd660d598f18260f5df3ce .claude/rules/mcp.md: sha256:ddfe965e7cf8cddbba1374c6ae582a20ac0af17c958bf10e1a4edff6ff2ad0b8 .claude/rules/testing.md: sha256:52bfc6e67b38d17b767d34c480530cd14c700ef8018d5c70c2deaa7133717904 @@ -124,10 +124,10 @@ local_deployed_file_hashes: .cursor/commands/sippy-generate-release-views.md: sha256:eb4c9eeeea2ab2a90e8a8839147d8a1a309ea6ce3dafd397c6d2485c93068a9a .cursor/commands/sippy-update-ga-release-views.md: sha256:4a5589bacc05127e427a2de4d34a8f13e05e297bdf6ebf7473c9e71f47a6b4f4 .cursor/commands/sippy-update-job-variant.md: sha256:f88742dddeec5024931959a8330fdce362ffdd9b8825e808830ac346605cbd16 - .cursor/rules/backend.mdc: sha256:5ff740fc76c25ee64beb7f768399da6ef6510ecca8147e5c2f81fbb110043084 + .cursor/rules/backend.mdc: sha256:9a3b22b249269e105a3958ac7a7fa8e78b5426347239c02567116f1acbf77ca1 .cursor/rules/config.mdc: sha256:d6e2195399bbb26a3fef7e54bd01862ffce39d89dce8aabdb0b89c89028192eb .cursor/rules/dev-commands.mdc: sha256:3dc8a3ef5cbb22ebb09b7fe3785f24a32ab2baa4488c5514c9c6210b0af6c4eb - .cursor/rules/frontend.mdc: sha256:497f39372724f1ae127181fe3dac9ea9a95a51c532b68ccfee6080832cf9c556 + .cursor/rules/frontend.mdc: sha256:6cbbb94363b782dc6763344a72f5a996fe25a4bd5db08862e578b611ed083612 .cursor/rules/general.mdc: sha256:5bc6e1e12d53d85656248c9dc1239c74bcc0df29d5987f3b08e3d79e3df413b7 .cursor/rules/mcp.mdc: sha256:c02472afd46e4c89f71d4487dcd5da98b0c1bcbcf7f9cbc4d7ed4e7d3a206ec1 .cursor/rules/testing.mdc: sha256:12ea7763599eb56fdd92ae09a9fed19495b17673ee34701803a6eae7ac50d3e8 diff --git a/pkg/api/README.md b/pkg/api/README.md index b4ffd59cd1..3e982bb868 100644 --- a/pkg/api/README.md +++ b/pkg/api/README.md @@ -294,95 +294,95 @@ A summary of runs for job(s). Results contains of the following values for each "name": "periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6", "results": [ { - "timestamp": 1628207039000, + "timestamp": "2021-08-06T03:43:59Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423429598720299008" }, { - "timestamp": 1628045973000, + "timestamp": "2021-08-04T03:59:33Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1422754032564310016" }, { - "timestamp": 1628198644000, + "timestamp": "2021-08-05T21:24:04Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423394362347229184" }, { - "timestamp": 1628485392000, + "timestamp": "2021-08-09T05:03:12Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1424597097709047808" }, { - "timestamp": 1628343908000, + "timestamp": "2021-08-07T14:25:08Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1424003666343366656" }, { - "timestamp": 1628325313000, + "timestamp": "2021-08-07T09:15:13Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423925674229370880" }, { - "timestamp": 1628289649000, + "timestamp": "2021-08-06T23:20:49Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423776089259380736" }, { - "timestamp": 1628277370000, + "timestamp": "2021-08-06T19:56:10Z", "result": "S", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423724523844276224" }, { - "timestamp": 1628358891000, + "timestamp": "2021-08-07T18:34:51Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1424066513538650112" }, { - "timestamp": 1628190532000, + "timestamp": "2021-08-05T19:08:52Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423360364472438784" }, { - "timestamp": 1628274962000, + "timestamp": "2021-08-06T19:16:02Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1423714481237659648" }, { - "timestamp": 1627391095000, + "timestamp": "2021-07-27T13:24:55Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1420007279679246336" }, { - "timestamp": 1627473363000, + "timestamp": "2021-07-28T12:16:03Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1420352338517823488" }, { - "timestamp": 1627617630000, + "timestamp": "2021-07-30T04:20:30Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1420957438630170624" }, { - "timestamp": 1627515377000, + "timestamp": "2021-07-29T00:56:17Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1420528516700573696" }, { - "timestamp": 1627396851000, + "timestamp": "2021-07-27T15:00:51Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1420031423921786880" }, { - "timestamp": 1627363991000, + "timestamp": "2021-07-27T05:53:11Z", "result": "F", "url": "https://prow.ci.openshift.org/view/gcs/origin-ci-test/logs/periodic-ci-openshift-release-master-nightly-4.9-e2e-metal-ipi-ovn-ipv6/1419893597473345536" } ] } ], - "start": 1627317573000, - "end": 1628508950000 + "start": "2021-07-26", + "end": "2021-08-09" } ``` diff --git a/pkg/api/componentreadiness/dataprovider/bigquery/releasedates.go b/pkg/api/componentreadiness/dataprovider/bigquery/releasedates.go index 9f6fea2e4b..fa1dcce671 100644 --- a/pkg/api/componentreadiness/dataprovider/bigquery/releasedates.go +++ b/pkg/api/componentreadiness/dataprovider/bigquery/releasedates.go @@ -2,6 +2,7 @@ package bigquery import ( "context" + "time" "github.com/openshift/sippy/pkg/api" "github.com/openshift/sippy/pkg/apis/api/componentreport/crtest" @@ -34,9 +35,10 @@ func (c *releaseDateQuerier) QueryReleaseDates(ctx context.Context) ([]crtest.Re for _, release := range releases { timeRange := crtest.ReleaseTimeRange{Release: release.Release} if release.GADate != nil { - prior := util.AdjustReleaseTime(*release.GADate, true, "30", c.reqOptions.CacheOption.CRTimeRoundingFactor, c.reqOptions.CacheOption.CRTimeRoundingOffset) + gaTime := release.GADate.In(time.UTC) + prior := util.AdjustReleaseTime(gaTime, true, "30", c.reqOptions.CacheOption.CRTimeRoundingFactor, c.reqOptions.CacheOption.CRTimeRoundingOffset) timeRange.Start = &prior - timeRange.End = release.GADate + timeRange.End = &gaTime } timeRanges = append(timeRanges, timeRange) } diff --git a/pkg/api/componentreadiness/dataprovider/postgres/provider.go b/pkg/api/componentreadiness/dataprovider/postgres/provider.go index 3ef5e2281a..bbadb12a6e 100644 --- a/pkg/api/componentreadiness/dataprovider/postgres/provider.go +++ b/pkg/api/componentreadiness/dataprovider/postgres/provider.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "cloud.google.com/go/civil" "github.com/lib/pq" "github.com/openshift/sippy/pkg/api/componentreadiness/dataprovider" @@ -165,7 +166,7 @@ func (p *PostgresProvider) QueryReleases(ctx context.Context) ([]v1.Release, err v1.SippyClassicCap: true, } - now := time.Now().UTC() + today := civil.DateOf(time.Now().UTC()) var releases []v1.Release for _, name := range releaseNames { rel := v1.Release{ @@ -176,7 +177,7 @@ func (p *PostgresProvider) QueryReleases(ctx context.Context) ([]v1.Release, err if meta, ok := releaseMetadata[name]; ok { rel.PreviousRelease = meta.previousRelease if meta.gaOffsetDays != 0 { - ga := now.AddDate(0, 0, meta.gaOffsetDays) + ga := today.AddDays(meta.gaOffsetDays) rel.GADate = &ga } if meta.product != "" { diff --git a/pkg/api/componentreadiness/queryparamparser_test.go b/pkg/api/componentreadiness/queryparamparser_test.go index 164280f3ec..6ae2b49a71 100644 --- a/pkg/api/componentreadiness/queryparamparser_test.go +++ b/pkg/api/componentreadiness/queryparamparser_test.go @@ -32,8 +32,8 @@ var ( func TestParseComponentReportRequest(t *testing.T) { releases := []v1.Release{ - {Release: "4.16", Status: "", GADate: util.DatePtr(2024, 6, 27, 0, 0, 0, 0, time.UTC)}, - {Release: "4.15", Status: "", GADate: util.DatePtr(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, + {Release: "4.16", Status: "", GADate: util.CivilDatePtr(2024, time.June, 27)}, + {Release: "4.15", Status: "", GADate: util.CivilDatePtr(2024, time.February, 28)}, } allJobVariants := crtest.JobVariants{Variants: map[string][]string{ @@ -491,8 +491,8 @@ func TestHATEOASLinkCacheConsistency(t *testing.T) { roundingOffset := 4 * time.Hour releases := []v1.Release{ - {Release: "4.16", Status: "", GADate: util.DatePtr(2024, 6, 27, 0, 0, 0, 0, time.UTC)}, - {Release: "4.17", Status: "", GADate: util.DatePtr(2024, 12, 10, 0, 0, 0, 0, time.UTC)}, + {Release: "4.16", Status: "", GADate: util.CivilDatePtr(2024, time.June, 27)}, + {Release: "4.17", Status: "", GADate: util.CivilDatePtr(2024, time.December, 10)}, } allJobVariants := crtest.JobVariants{Variants: map[string][]string{ diff --git a/pkg/api/componentreadiness/triage_test.go b/pkg/api/componentreadiness/triage_test.go index 30575c5ced..9c082f73a1 100644 --- a/pkg/api/componentreadiness/triage_test.go +++ b/pkg/api/componentreadiness/triage_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "cloud.google.com/go/civil" "github.com/lib/pq" "github.com/openshift/sippy/pkg/apis/api/componentreport/crview" "github.com/openshift/sippy/pkg/apis/api/componentreport/reqopts" @@ -611,7 +612,7 @@ func TestCompareTriageObjects(t *testing.T) { } func TestInjectRegressionHATEOASLinks(t *testing.T) { - ga421 := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + ga421 := civil.Date{Year: 2025, Month: 6, Day: 1} releases := []v1.Release{ {Release: "4.20", GADate: &ga421}, {Release: "4.21", GADate: &ga421}, diff --git a/pkg/api/componentreadiness/utils/utils_test.go b/pkg/api/componentreadiness/utils/utils_test.go index f3318a27f4..238b55fca9 100644 --- a/pkg/api/componentreadiness/utils/utils_test.go +++ b/pkg/api/componentreadiness/utils/utils_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "cloud.google.com/go/civil" "github.com/openshift/sippy/pkg/apis/api/componentreport/crview" "github.com/openshift/sippy/pkg/apis/api/componentreport/reqopts" v1 "github.com/openshift/sippy/pkg/apis/sippy/v1" @@ -15,8 +16,8 @@ import ( func TestGenerateTestDetailsURL(t *testing.T) { // Define releases with GA dates for all tests - ga419 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - ga420 := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + ga419 := civil.Date{Year: 2025, Month: 1, Day: 1} + ga420 := civil.Date{Year: 2025, Month: 6, Day: 1} releases := []v1.Release{ { Release: "4.19", diff --git a/pkg/api/job_runs.go b/pkg/api/job_runs.go index 7fa69ec96a..d65b1fe897 100644 --- a/pkg/api/job_runs.go +++ b/pkg/api/job_runs.go @@ -117,7 +117,7 @@ func JobsRunsReportFromDB(dbc *db.DB, filterOpts *filter.FilterOptions, release q = q.Where("release = ?", release) } - q = q.Where("timestamp < ?", reportEnd.UnixMilli()) + q = q.Where("timestamp < ?", reportEnd) // Get the row count before pagination var rowCount int64 diff --git a/pkg/api/jobs.go b/pkg/api/jobs.go index 6de4954f95..ae91a512c8 100644 --- a/pkg/api/jobs.go +++ b/pkg/api/jobs.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + "cloud.google.com/go/civil" log "github.com/sirupsen/logrus" apitype "github.com/openshift/sippy/pkg/apis/api" @@ -181,6 +182,8 @@ func PrintJobsReportFromDB(w http.ResponseWriter, req *http.Request, return } + filterOpts.Filter = filter.StripJobRunFilters(filterOpts.Filter) + jobsResult, err := JobReportsFromDB(dbc, release, req.URL.Query().Get("period"), filterOpts, start, boundary, end, reportEnd) if err != nil { RespondWithJSON(http.StatusInternalServerError, w, map[string]interface{}{"code": http.StatusInternalServerError, "message": "Error building job report:" + err.Error()}) @@ -238,8 +241,8 @@ type jobDetail struct { type jobDetailAPIResult struct { Jobs []jobDetail `json:"jobs"` - Start int `json:"start"` - End int `json:"end"` + Start civil.Date `json:"start"` + End civil.Date `json:"end"` } func (jobs jobDetailAPIResult) limit(req *http.Request) jobDetailAPIResult { @@ -272,7 +275,8 @@ func JobDetailsReport(dbc *db.DB, release, jobSearchStr string, reportEnd time.T // PrintJobDetailsReportFromDB renders the detailed list of runs for matching jobs. func PrintJobDetailsReportFromDB(w http.ResponseWriter, req *http.Request, dbc *db.DB, release, jobSearchStr string, reportEnd time.Time) error { - var start, end int + end := civil.DateOf(reportEnd.UTC()) + start := end.AddDays(-14) prowJobRuns, err := JobDetailsReport(dbc, release, jobSearchStr, reportEnd) if err != nil { @@ -303,7 +307,7 @@ func PrintJobDetailsReportFromDB(w http.ResponseWriter, req *http.Request, dbc * InfrastructureFailure: pjr.InfrastructureFailure, KnownFailure: pjr.KnownFailure, Succeeded: pjr.Succeeded, - Timestamp: int(pjr.Timestamp.Unix() * 1000), + Timestamp: pjr.Timestamp, OverallResult: pjr.OverallResult, } jobDetails[jobName].Results = append(jobDetails[jobName].Results, newRun) diff --git a/pkg/api/releases.go b/pkg/api/releases.go index c543450568..6680e7c8b6 100644 --- a/pkg/api/releases.go +++ b/pkg/api/releases.go @@ -9,6 +9,7 @@ import ( "sort" "time" + "cloud.google.com/go/civil" "github.com/lib/pq" pkgerrors "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -497,12 +498,10 @@ func transformRelease(r sippyv1.ReleaseRow) sippyv1.Release { Product: r.Product.StringVal, } if r.GADate.Valid { - gaDate := r.GADate.Date.In(time.UTC) - release.GADate = &gaDate + release.GADate = &r.GADate.Date } if r.DevelStartDate.IsValid() { - develStartDate := r.DevelStartDate.In(time.UTC) - release.DevelopmentStartDate = &develStartDate + release.DevelopmentStartDate = &r.DevelStartDate } if r.Capabilities != nil { for _, capability := range r.Capabilities { @@ -514,7 +513,7 @@ func transformRelease(r sippyv1.ReleaseRow) sippyv1.Release { // BuildReleasesResponse creates the API response structure for releases func BuildReleasesResponse(releases []sippyv1.Release, lastUpdated time.Time) apitype.Releases { - gaDateMap := make(map[string]time.Time) + gaDateMap := make(map[string]civil.Date) dateMap := make(map[string]apitype.ReleaseDates) response := apitype.Releases{ DeprecatedGADates: gaDateMap, diff --git a/pkg/api/releases_test.go b/pkg/api/releases_test.go index 62967569cb..0c060cd2a1 100644 --- a/pkg/api/releases_test.go +++ b/pkg/api/releases_test.go @@ -2,7 +2,6 @@ package api import ( "testing" - "time" "cloud.google.com/go/bigquery" "cloud.google.com/go/civil" @@ -17,9 +16,9 @@ import ( func TestTransformRelease(t *testing.T) { - devStart420, _ := time.Parse(time.RFC3339, "2025-04-18T00:00:00.00Z") - devStart419, _ := time.Parse(time.RFC3339, "2024-11-25T00:00:00.00Z") - gaDate419, _ := time.Parse(time.RFC3339, "2025-05-09T00:00:00.00Z") + devStart420 := civil.Date{Year: 2025, Month: 4, Day: 18} + devStart419 := civil.Date{Year: 2024, Month: 11, Day: 25} + gaDate419 := civil.Date{Year: 2025, Month: 5, Day: 9} tests := []struct { name string diff --git a/pkg/api/test_analysis.go b/pkg/api/test_analysis.go index b2043e5d91..b938aa9cf9 100644 --- a/pkg/api/test_analysis.go +++ b/pkg/api/test_analysis.go @@ -3,6 +3,7 @@ package api import ( "time" + "cloud.google.com/go/civil" log "github.com/sirupsen/logrus" "github.com/openshift/sippy/pkg/db" @@ -10,15 +11,15 @@ import ( ) type CountByDate struct { - Date string `json:"date"` - Group string `json:"group"` - PassPercentage float64 `json:"pass_percentage"` - FlakePercentage float64 `json:"flake_percentage"` - FailPercentage float64 `json:"fail_percentage"` - Runs int `json:"runs"` - Passes int `json:"passes"` - Flakes int `json:"flakes"` - Failures int `json:"failures"` + Date civil.Date `json:"date"` + Group string `json:"group"` + PassPercentage float64 `json:"pass_percentage"` + FlakePercentage float64 `json:"flake_percentage"` + FailPercentage float64 `json:"fail_percentage"` + Runs int `json:"runs"` + Passes int `json:"passes"` + Flakes int `json:"flakes"` + Failures int `json:"failures"` } func GetTestAnalysisOverallFromDB(dbc *db.DB, filters *filter.Filter, release, testName string, reportEnd time.Time) (map[string][]CountByDate, error) { @@ -26,7 +27,7 @@ func GetTestAnalysisOverallFromDB(dbc *db.DB, filters *filter.Filter, release, t jq := dbc.DB.Table("test_analysis_by_job_by_dates"). Select(`test_id, test_name, - to_date((date at time zone 'UTC')::text, 'YYYY-MM-DD'::text)::text as date, + date, 'overall' as group, SUM(runs) as runs, SUM(passes) as passes, @@ -38,7 +39,8 @@ func GetTestAnalysisOverallFromDB(dbc *db.DB, filters *filter.Filter, release, t Joins("JOIN prow_jobs on prow_jobs.name = job_name"). Where("test_analysis_by_job_by_dates.release = ?", release). Where("test_name = ?", testName). - Where("date >= ?", time.Now().Add(-24*14*time.Hour)). + Where("date >= ?", reportEnd.Add(-24*14*time.Hour)). + Where("date <= ?", reportEnd). Order("date ASC"). Group("date, test_id, test_name, test_analysis_by_job_by_dates.release") @@ -89,7 +91,7 @@ func GetTestAnalysisByJobFromDB(dbc *db.DB, filters *filter.Filter, release, tes jq := dbc.DB.Table("test_analysis_by_job_by_dates"). Select(`test_id, test_name, - to_date((date at time zone 'UTC')::text, 'YYYY-MM-DD'::text)::text as date, + date, prow_jobs.release, job_name as group, runs, @@ -158,7 +160,7 @@ func GetTestAnalysisByVariantFromDB(dbc *db.DB, filters *filter.Filter, release, Where("release = ?", release). Where("test_name = ?", testName). Where("date <= ?", reportEnd). - Select(`to_date((date at time zone 'UTC')::text, 'YYYY-MM-DD'::text)::text as date, + Select(`date, variant as group, runs, passes, diff --git a/pkg/apis/api/types.go b/pkg/apis/api/types.go index 66201c52f9..119eb641e0 100644 --- a/pkg/apis/api/types.go +++ b/pkg/apis/api/types.go @@ -7,6 +7,7 @@ import ( "time" bq "cloud.google.com/go/bigquery" + "cloud.google.com/go/civil" "github.com/lib/pq" "github.com/openshift/sippy/pkg/apis/api/componentreport/crview" @@ -355,7 +356,7 @@ type JobRun struct { InfrastructureFailure bool `json:"infrastructure_failure"` KnownFailure bool `json:"known_failure"` Succeeded bool `json:"succeeded"` - Timestamp int `json:"timestamp"` + Timestamp time.Time `json:"timestamp"` OverallResult v1.JobOverallResult `json:"overall_result"` PullRequestOrg string `json:"pull_request_org"` PullRequestRepo string `json:"pull_request_repo"` @@ -394,7 +395,7 @@ func (run JobRun) GetFieldType(param string) ColumnType { case "test_grid_url": return ColumnTypeString case "timestamp": - return ColumnTypeNumerical + return ColumnTypeTimestamp case "pull_request_org": return ColumnTypeString case "pull_request_repo": @@ -442,7 +443,7 @@ func (run JobRun) GetNumericalValue(param string) (float64, error) { case "test_failures": return float64(run.TestFailures), nil case "timestamp": - return float64(run.Timestamp), nil + return float64(run.Timestamp.UnixMilli()), nil default: return 0, fmt.Errorf("unknown numerical field %s", param) } @@ -865,13 +866,13 @@ type JobPayload struct { // CalendarEvent is an API type representing a FullCalendar.io event type, for use // with calendering. type CalendarEvent struct { - Title string `json:"title"` - Start string `json:"start"` - End string `json:"end"` - AllDay bool `json:"allDay"` - Display string `json:"display,omitempty"` - Phase string `json:"phase"` - JIRA string `json:"jira"` + Title string `json:"title"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + AllDay bool `json:"allDay"` + Display string `json:"display,omitempty"` + Phase string `json:"phase"` + JIRA string `json:"jira"` } type BuildClusterHealthAnalysis struct { @@ -907,8 +908,8 @@ type TestOutputBigQuery struct { } type ReleaseDates struct { - GA *time.Time `json:"ga,omitempty"` - DevelopmentStart *time.Time `json:"development_start,omitempty"` + GA *civil.Date `json:"ga,omitempty"` + DevelopmentStart *civil.Date `json:"development_start,omitempty"` } type Release struct { // this is the Release that goes out to the UI Name string `json:"name"` @@ -919,7 +920,7 @@ type Release struct { // this is the Release that goes out to the UI } type Releases struct { Releases []string `json:"releases"` - DeprecatedGADates map[string]time.Time `json:"ga_dates"` + DeprecatedGADates map[string]civil.Date `json:"ga_dates"` Dates map[string]ReleaseDates `json:"dates"` LastUpdated time.Time `json:"last_updated"` ReleaseAttrs map[string]Release `json:"release_attrs"` diff --git a/pkg/apis/sippy/v1/types.go b/pkg/apis/sippy/v1/types.go index 0daa96f0fe..b6d871e797 100644 --- a/pkg/apis/sippy/v1/types.go +++ b/pkg/apis/sippy/v1/types.go @@ -1,8 +1,6 @@ package v1 import ( - "time" - "cloud.google.com/go/bigquery" "cloud.google.com/go/civil" bugsv1 "github.com/openshift/sippy/pkg/apis/bugs/v1" @@ -73,8 +71,8 @@ type FailureGroup struct { type Release struct { // this is the Release that gets cached Release string Status string - GADate *time.Time - DevelopmentStartDate *time.Time + GADate *civil.Date + DevelopmentStartDate *civil.Date PreviousRelease string Capabilities map[ReleaseCapability]bool Product string diff --git a/pkg/apis/sippyprocessing/v1/types.go b/pkg/apis/sippyprocessing/v1/types.go index 50fb89587b..141a5cfc33 100644 --- a/pkg/apis/sippyprocessing/v1/types.go +++ b/pkg/apis/sippyprocessing/v1/types.go @@ -3,6 +3,8 @@ package v1 import ( + "time" + bugsv1 "github.com/openshift/sippy/pkg/apis/bugs/v1" ) @@ -114,10 +116,9 @@ type JobRunResult struct { // InfrastructureFailure is true if the job run failed, for reasons which appear to be related to test/CI infra. InfrastructureFailure bool `json:"infrastructureFailure"` // KnownFailure is true if the job run failed, but we found a bug that is likely related already filed. - KnownFailure bool `json:"knownFailure"` - Succeeded bool `json:"succeeded"` - // Timestamp is milliseconds since epoch when this job was run. - Timestamp int `json:"timestamp"` + KnownFailure bool `json:"knownFailure"` + Succeeded bool `json:"succeeded"` + Timestamp time.Time `json:"timestamp"` OverallResult JobOverallResult `json:"result"` } @@ -147,7 +148,7 @@ type RawJobResult struct { // It is used to build up a complete set of successes and failure, but until all the testgrid results have been checked, it will be incomplete type RawTestResult struct { Name string - Timestamps []int + Timestamps []time.Time Successes int Failures int Flakes int @@ -190,8 +191,7 @@ type RawJobRunResult struct { // Overall result OverallResult JobOverallResult - // Timestamp - Timestamp int + Timestamp time.Time } type OperatorState struct { diff --git a/pkg/db/db.go b/pkg/db/db.go index 3a76773d93..8aaa326c65 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -89,6 +89,7 @@ func New(dsn string, logLevel gormlogger.LogLevel, opts ...Option) (*DB, error) // partitions. Custom plans use actual parameter values for partition pruning. pgxConfig.RuntimeParams["plan_cache_mode"] = "force_custom_plan" pgxConfig.RuntimeParams["work_mem"] = "128MB" + pgxConfig.RuntimeParams["timezone"] = "UTC" if cfg.enablePartitionwise { pgxConfig.RuntimeParams["enable_partitionwise_aggregate"] = "on" pgxConfig.RuntimeParams["enable_partitionwise_join"] = "on" diff --git a/pkg/db/views.go b/pkg/db/views.go index fc6d53c699..97b6af5475 100644 --- a/pkg/db/views.go +++ b/pkg/db/views.go @@ -263,7 +263,7 @@ SELECT prow_job_runs.id, prow_job_runs.succeeded, prow_job_runs.infrastructure_failure, prow_job_runs.known_failure, - (EXTRACT(epoch FROM (prow_job_runs."timestamp" AT TIME ZONE 'utc'::text)) * 1000::numeric)::bigint AS "timestamp", + prow_job_runs."timestamp", prow_job_runs.id AS prow_id, prow_job_runs.cluster AS cluster, prow_job_runs.labels as labels, diff --git a/pkg/filter/filterable.go b/pkg/filter/filterable.go index 469cfe2a3f..e4daece819 100644 --- a/pkg/filter/filterable.go +++ b/pkg/filter/filterable.go @@ -12,6 +12,7 @@ import ( log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" + "k8s.io/apimachinery/pkg/util/sets" apitype "github.com/openshift/sippy/pkg/apis/api" apiparam "github.com/openshift/sippy/pkg/util/param" @@ -55,6 +56,25 @@ type Filter struct { LinkOperator LinkOperator `json:"linkOperator"` } +// jobRunFields are filter fields that belong to job runs, not jobs. +var jobRunFields = sets.NewString("timestamp", "cluster") + +// StripJobRunFilters returns a copy of the filter with job-run-specific +// fields (timestamp, cluster) removed. Use this when applying filters +// to a jobs query that doesn't have those columns. +func StripJobRunFilters(fil *Filter) *Filter { + if fil == nil { + return nil + } + result := &Filter{LinkOperator: fil.LinkOperator} + for _, item := range fil.Items { + if !jobRunFields.Has(item.Field) { + result.Items = append(result.Items, item) + } + } + return result +} + // FilterItem is an individual filter consisting of a field, operator, // value and a not boolean that negates the operator. For example: // name contains aws, or name not contains aws. @@ -105,9 +125,6 @@ func (f FilterItem) isEmptyFilter(field string, filterable Filterable, forBQ boo func (f FilterItem) orFilterToSQL(db *gorm.DB, filterable Filterable) (orFilter string, orParams interface{}) { //nolint field := fmt.Sprintf("%q", f.Field) - if filterable != nil && filterable.GetFieldType(f.Field) == apitype.ColumnTypeTimestamp { - field = fmt.Sprintf("extract(epoch from %s at time zone 'utc') * 1000", f.Field) - } switch f.Operator { case OperatorHasEntry: @@ -162,9 +179,6 @@ func (f FilterItem) orFilterToSQL(db *gorm.DB, filterable Filterable) (orFilter func (f FilterItem) andFilterToSQL(db *gorm.DB, filterable Filterable) *gorm.DB { //nolint field := fmt.Sprintf("%q", f.Field) - if filterable != nil && filterable.GetFieldType(f.Field) == apitype.ColumnTypeTimestamp { - field = fmt.Sprintf("extract(epoch from %s at time zone 'utc') * 1000", f.Field) - } switch f.Operator { case OperatorHasEntry: @@ -226,9 +240,6 @@ func (f FilterItem) andFilterToSQL(db *gorm.DB, filterable Filterable) *gorm.DB func (f FilterItem) toBQStr(filterable Filterable, paramIndex int) (sql string, params []bigquery.QueryParameter) { //nolint field := strings.ReplaceAll(fmt.Sprintf("%q", f.Field), "\"", "") - if filterable != nil && filterable.GetFieldType(f.Field) == apitype.ColumnTypeTimestamp { - field = fmt.Sprintf("extract(epoch from %s at time zone 'utc') * 1000", f.Field) - } // Helper to create a parameter paramName := fmt.Sprintf("filterParam%d", paramIndex+1) @@ -660,7 +671,7 @@ func filterArray(filter FilterItem, item Filterable) (bool, error) { func Compare(a, b Filterable, sortField string) bool { kind := a.GetFieldType(sortField) - if kind == apitype.ColumnTypeNumerical { + if kind == apitype.ColumnTypeNumerical || kind == apitype.ColumnTypeTimestamp { val1, err := a.GetNumericalValue(sortField) if err != nil { log.Error(err) diff --git a/pkg/sippyserver/chat_conversations.go b/pkg/sippyserver/chat_conversations.go index a4e5b331ea..df058615ed 100644 --- a/pkg/sippyserver/chat_conversations.go +++ b/pkg/sippyserver/chat_conversations.go @@ -29,7 +29,7 @@ type CreateChatConversationRequest struct { // ChatConversationResponse is the response for a chat conversation with HATEOAS links type ChatConversationResponse struct { ID uuid.UUID `json:"id"` - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` User string `json:"user"` Links map[string]string `json:"links"` } @@ -114,7 +114,7 @@ func (s *Server) jsonCreateChatConversation(w http.ResponseWriter, req *http.Req baseURL := api.GetBaseURL(req) response := ChatConversationResponse{ ID: conversation.ID, - CreatedAt: conversation.CreatedAt.Format(time.RFC3339), + CreatedAt: conversation.CreatedAt, User: conversation.User, Links: map[string]string{ "self": fmt.Sprintf("%s/api/chat/conversations/%s", baseURL, conversation.ID.String()), diff --git a/pkg/sippyserver/parameters.go b/pkg/sippyserver/parameters.go index 835adcf538..69dedaa728 100644 --- a/pkg/sippyserver/parameters.go +++ b/pkg/sippyserver/parameters.go @@ -112,7 +112,7 @@ func getSortParams(req *http.Request) (string, apitype.Sort) { return sortField, sort } -func splitJobAndJobRunFilters(fil *filter.Filter) (*filter.Filter, *filter.Filter, error) { +func splitJobAndJobRunFilters(fil *filter.Filter) (*filter.Filter, *filter.Filter) { // This function is used by APIs that are largely interested in filtering on the jobs, // but there is a case for filtering by the timestamp or build cluster on a job run. // Break apart the filter we're given for the respective queries: @@ -125,12 +125,6 @@ func splitJobAndJobRunFilters(fil *filter.Filter) (*filter.Filter, *filter.Filte for _, f := range fil.Items { switch f.Field { case "timestamp": - ms, err := strconv.ParseInt(f.Value, 0, 64) - if err != nil { - return nil, nil, err - } - - f.Value = time.Unix(0, ms*int64(time.Millisecond)).Format("2006-01-02T15:04:05-0700") jobRunsFilter.Items = append(jobRunsFilter.Items, f) case "cluster": jobRunsFilter.Items = append(jobRunsFilter.Items, f) @@ -138,5 +132,5 @@ func splitJobAndJobRunFilters(fil *filter.Filter) (*filter.Filter, *filter.Filte jobFilter.Items = append(jobFilter.Items, f) } } - return jobFilter, jobRunsFilter, nil + return jobFilter, jobRunsFilter } diff --git a/pkg/sippyserver/server.go b/pkg/sippyserver/server.go index 266d756a51..7d9ee6faad 100644 --- a/pkg/sippyserver/server.go +++ b/pkg/sippyserver/server.go @@ -1098,11 +1098,7 @@ func (s *Server) jsonJobBugsFromDB(w http.ResponseWriter, req *http.Request) { failureResponse(w, http.StatusBadRequest, "Could not marshal query: "+err.Error()) return } - jobFilter, _, err := splitJobAndJobRunFilters(fil) - if err != nil { - failureResponse(w, http.StatusBadRequest, "Could not marshal query: "+err.Error()) - return - } + jobFilter, _ := splitJobAndJobRunFilters(fil) start, boundary, end := getPeriodDates("default", req, s.GetReportEnd()) limit := getLimitParam(req) @@ -1632,11 +1628,7 @@ func (s *Server) jsonJobsAnalysisFromDB(w http.ResponseWriter, req *http.Request failureResponse(w, http.StatusBadRequest, "Could not marshal query: "+err.Error()) return } - jobFilter, jobRunsFilter, err := splitJobAndJobRunFilters(fil) - if err != nil { - failureResponse(w, http.StatusBadRequest, "Could not marshal query: "+err.Error()) - return - } + jobFilter, jobRunsFilter := splitJobAndJobRunFilters(fil) start, boundary, end := getPeriodDates("default", req, s.GetReportEnd()) limit := getLimitParam(req) diff --git a/pkg/util/utils.go b/pkg/util/utils.go index 0896b22e15..b8568989ff 100644 --- a/pkg/util/utils.go +++ b/pkg/util/utils.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + "cloud.google.com/go/civil" v1 "github.com/openshift/sippy/pkg/apis/sippy/v1" ) @@ -107,6 +108,11 @@ func DatePtr(year int, month time.Month, day, hour, minute, sec, nsec int, loc * return &d } +func CivilDatePtr(year int, month time.Month, day int) *civil.Date { + d := civil.Date{Year: year, Month: month, Day: day} + return &d +} + // releaseRelativeRE is a custom format we allow for times relative to now, or a releases ga date // (i.e. now-7d, ga-30d, ga, etc var releaseRelativeRE = regexp.MustCompile(`^(now|ga|end)(?:-([0-9]+)([d]))?$`) @@ -132,7 +138,7 @@ func ParseCRReleaseTime(allReleases []v1.Release, release, timeStr string, isSta gaDateMap := map[string]time.Time{} for _, r := range allReleases { if r.GADate != nil { - gaDateMap[r.Release] = *r.GADate + gaDateMap[r.Release] = r.GADate.In(time.UTC) } } diff --git a/pkg/util/utils_test.go b/pkg/util/utils_test.go index 4f1110c823..ed914a68d2 100644 --- a/pkg/util/utils_test.go +++ b/pkg/util/utils_test.go @@ -11,7 +11,7 @@ import ( func TestParseCRReleaseTime(t *testing.T) { releases := []v1.Release{ - {Release: "4.16", Status: "", GADate: DatePtr(2024, 6, 27, 0, 0, 0, 0, time.UTC)}, + {Release: "4.16", Status: "", GADate: CivilDatePtr(2024, time.June, 27)}, } nowMinus7d := time.Now().Add(-7 * 24 * time.Hour).UTC() diff --git a/sippy-ng/AGENTS.md b/sippy-ng/AGENTS.md index c0f03915d3..a73b497d08 100644 --- a/sippy-ng/AGENTS.md +++ b/sippy-ng/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - + @@ -19,6 +19,13 @@ npx prettier --write . The frontend uses `npm`. If you must install or update any dependencies, always use the `--ignore-scripts` flag. +* **Timestamps and dates from the API**: + - Timestamps arrive as RFC 3339 strings (e.g., `"2024-06-27T15:30:00Z"`), not epoch millisecond integers. Use `new Date(value)` or `Temporal.Instant.from(value)` to parse them. + - Dates arrive as `YYYY-MM-DD` strings (e.g., `"2024-06-27"`). Use `Temporal.PlainDate.from(value)` for date arithmetic. + - For MUI DataGrid timestamp columns, use `type: 'date'` with a `valueGetter` that returns a `Date` object. Do not return epoch milliseconds from `valueGetter`. + - For filter values sent to the API, use ISO 8601 strings (e.g., `new Date(...).toISOString()`), not epoch millisecond integers. + - For day-level bucketing or date arithmetic, prefer `Temporal.PlainDate` over `Date` with manual millisecond math. + --- *This file was generated by APM CLI. Do not edit manually.* *To regenerate: `apm compile`* diff --git a/sippy-ng/CLAUDE.md b/sippy-ng/CLAUDE.md index a409d35af1..bb805987d5 100644 --- a/sippy-ng/CLAUDE.md +++ b/sippy-ng/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md - + # Project Standards @@ -20,6 +20,13 @@ npx prettier --write . The frontend uses `npm`. If you must install or update any dependencies, always use the `--ignore-scripts` flag. +* **Timestamps and dates from the API**: + - Timestamps arrive as RFC 3339 strings (e.g., `"2024-06-27T15:30:00Z"`), not epoch millisecond integers. Use `new Date(value)` or `Temporal.Instant.from(value)` to parse them. + - Dates arrive as `YYYY-MM-DD` strings (e.g., `"2024-06-27"`). Use `Temporal.PlainDate.from(value)` for date arithmetic. + - For MUI DataGrid timestamp columns, use `type: 'date'` with a `valueGetter` that returns a `Date` object. Do not return epoch milliseconds from `valueGetter`. + - For filter values sent to the API, use ISO 8601 strings (e.g., `new Date(...).toISOString()`), not epoch millisecond integers. + - For day-level bucketing or date arithmetic, prefer `Temporal.PlainDate` over `Date` with manual millisecond math. + --- *This file was generated by APM CLI. Do not edit manually.* *To regenerate: `apm compile`* diff --git a/sippy-ng/src/App.js b/sippy-ng/src/App.js index 988a5883a0..c67dfda305 100644 --- a/sippy-ng/src/App.js +++ b/sippy-ng/src/App.js @@ -476,13 +476,6 @@ function App(props) { ]) }) .then(([releases, sippyCapabilities, reportDate]) => { - // Remove the Z from the ga_dates so that when Date objects are created, - // the date is not converted to a local time zone. - for (const key in releases.ga_dates) { - if (releases.ga_dates[key]) { - releases.ga_dates[key] = releases.ga_dates[key].replace('Z', '') - } - } setReleases(releases) setSippyCapabilities(sippyCapabilities) setReportDate(reportDate['pinnedDateTime']) diff --git a/sippy-ng/src/build_clusters/BuildClusterDetails.js b/sippy-ng/src/build_clusters/BuildClusterDetails.js index f8bf8747fe..6dee216e5a 100644 --- a/sippy-ng/src/build_clusters/BuildClusterDetails.js +++ b/sippy-ng/src/build_clusters/BuildClusterDetails.js @@ -24,9 +24,7 @@ export default function BuildClusterDetails(props) { filterFor( 'timestamp', '>', - `${new Date( - startDate - 14 * 24 * 60 * 60 * 1000 - ).getTime()}` + new Date(startDate - 14 * 24 * 60 * 60 * 1000).toISOString() ), ], }} diff --git a/sippy-ng/src/component_readiness/RegressedTestsPanel.js b/sippy-ng/src/component_readiness/RegressedTestsPanel.js index 0252365448..4a5ba64dec 100644 --- a/sippy-ng/src/component_readiness/RegressedTestsPanel.js +++ b/sippy-ng/src/component_readiness/RegressedTestsPanel.js @@ -217,16 +217,17 @@ export default function RegressedTestsPanel(props) { headerName: 'Regressed Since', flex: 12, filterable: false, + type: 'date', valueGetter: (params) => { if (!params.row.regression?.opened) { // For a regression we haven't yet detected: return null } - return new Date(params.row.regression.opened).getTime() + return new Date(params.row.regression.opened) }, renderCell: (params) => { if (!params.value) return '' - const regressedSinceDate = new Date(params.row.regression.opened) + const regressedSinceDate = params.value return ( { if (!params.row.last_failure) { return null } - return new Date(params.row.last_failure).getTime() + return new Date(params.row.last_failure) }, renderCell: (params) => { if (!params.value) return '' - const lastFailureDate = new Date(params.value) + const lastFailureDate = params.value return (
{relativeTime(lastFailureDate, new Date())} diff --git a/sippy-ng/src/component_readiness/TriagedRegressionTestList.js b/sippy-ng/src/component_readiness/TriagedRegressionTestList.js index 5b047b74bc..54d82ba7d3 100644 --- a/sippy-ng/src/component_readiness/TriagedRegressionTestList.js +++ b/sippy-ng/src/component_readiness/TriagedRegressionTestList.js @@ -217,15 +217,16 @@ export default function TriagedRegressionTestList(props) { headerName: 'Last Failure', flex: 12, filterable: false, + type: 'date', valueGetter: (params) => { if (!params.row.last_failure.Valid) { return null } - return new Date(params.row.last_failure.Time).getTime() + return new Date(params.row.last_failure.Time) }, renderCell: (params) => { if (!params.value) return '' - const lastFailureDate = new Date(params.value) + const lastFailureDate = params.value return (
{relativeTime(lastFailureDate, new Date())} diff --git a/sippy-ng/src/datagrid/GridToolbarFilterItem.js b/sippy-ng/src/datagrid/GridToolbarFilterItem.js index 9222ca08c4..48efd1103c 100644 --- a/sippy-ng/src/datagrid/GridToolbarFilterItem.js +++ b/sippy-ng/src/datagrid/GridToolbarFilterItem.js @@ -12,6 +12,7 @@ import { } from '@mui/material' import { Close } from '@mui/icons-material' import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' +import { isValid } from 'date-fns' import { makeStyles } from '@mui/styles' import GridToolbarAutocomplete from './GridToolbarAutocomplete' import GridToolbarClientAutocomplete from './GridToolbarClientAutocomplete' @@ -102,14 +103,15 @@ export default function GridToolbarFilterItem(props) { value={ props.filterModel.value === '' ? null - : new Date(parseInt(props.filterModel.value)) + : new Date(props.filterModel.value) } onChange={(e) => { - if (e && e.getTime()) { + if (e && isValid(e)) { props.setFilterModel({ columnField: props.filterModel.columnField, + not: props.filterModel.not, operatorValue: props.filterModel.operatorValue, - value: e.getTime().toString(), + value: e.toISOString(), }) } }} diff --git a/sippy-ng/src/datagrid/utils.js b/sippy-ng/src/datagrid/utils.js index 7568de7076..87df9a0413 100644 --- a/sippy-ng/src/datagrid/utils.js +++ b/sippy-ng/src/datagrid/utils.js @@ -86,7 +86,7 @@ export function filterItemRenderValue(item) { let value = item.value let tooltip = null if (item.columnField === 'timestamp' && item.value !== '') { - let date = new Date(parseInt(item.value)) + let date = new Date(item.value) value = format(utcToZonedTime(date, 'UTC'), "yyyy-MM-dd HH:mm 'UTC'", { timeZone: 'Etc/UTC', }) diff --git a/sippy-ng/src/helpers.js b/sippy-ng/src/helpers.js index f1fc3e7767..e4400e0b3f 100644 --- a/sippy-ng/src/helpers.js +++ b/sippy-ng/src/helpers.js @@ -199,8 +199,8 @@ export function pathForVariantsWithTestFailure(release, variant, test) { } function last7DaysFilter() { - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 - return filterFor('timestamp', '>', `${sevenDaysAgo}`) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + return filterFor('timestamp', '>', sevenDaysAgo.toISOString()) } export function pathForJobRunsWithTestFailure(release, test, filter) { diff --git a/sippy-ng/src/jobs/JobRunsTable.js b/sippy-ng/src/jobs/JobRunsTable.js index 81c9e4ab71..fb59865755 100644 --- a/sippy-ng/src/jobs/JobRunsTable.js +++ b/sippy-ng/src/jobs/JobRunsTable.js @@ -162,13 +162,11 @@ export default function JobRunsTable(props) { filterable: true, flex: 1.25, type: 'date', - valueFormatter: (params) => { - return new Date(params.value) - }, + valueGetter: (params) => new Date(params.value), renderCell: (params) => { return ( - -

{new Date(params.value).toLocaleString()}

+ +

{params.value.toLocaleString()}

) }, diff --git a/sippy-ng/src/jobs/JobStackedChart.js b/sippy-ng/src/jobs/JobStackedChart.js index 9110ec558c..f51688cbac 100644 --- a/sippy-ng/src/jobs/JobStackedChart.js +++ b/sippy-ng/src/jobs/JobStackedChart.js @@ -12,7 +12,7 @@ export const dayFilter = (days, startDate) => { { columnField: 'timestamp', operatorValue: '>', - value: `${startDate - 1000 * 60 * 60 * 24 * days}`, + value: new Date(startDate - 1000 * 60 * 60 * 24 * days).toISOString(), }, ] } @@ -22,12 +22,16 @@ export const hourFilter = (dayOffset, startDate) => { { columnField: 'timestamp', operatorValue: '>', - value: `${startDate - dayOffset * 1000 * 60 * 60 * 24}`, + value: new Date( + startDate - dayOffset * 1000 * 60 * 60 * 24 + ).toISOString(), }, { columnField: 'timestamp', operatorValue: '<=', - value: `${startDate - (dayOffset - 1) * 1000 * 60 * 60 * 24}`, + value: new Date( + startDate - (dayOffset - 1) * 1000 * 60 * 60 * 24 + ).toISOString(), }, ] } diff --git a/sippy-ng/src/jobs/JobsDetail.js b/sippy-ng/src/jobs/JobsDetail.js index e8e5850ca0..0c7c4f312b 100644 --- a/sippy-ng/src/jobs/JobsDetail.js +++ b/sippy-ng/src/jobs/JobsDetail.js @@ -15,8 +15,6 @@ const useStyles = makeStyles((theme) => ({ }, })) -const msPerDay = 86400 * 1000 - /** * JobsDetail is the landing page for the JobDetailTable. */ @@ -56,10 +54,8 @@ export default function JobsDetail(props) { }) .then((response) => { setData(response) - setStartDate( - new Date(Math.floor(response.start / msPerDay) * msPerDay) - ) - setEndDate(new Date(Math.floor(response.end / msPerDay) * msPerDay)) + setStartDate(Temporal.PlainDate.from(response.start)) + setEndDate(Temporal.PlainDate.from(response.end)) setLoaded(true) }) .catch((error) => { @@ -103,53 +99,40 @@ export default function JobsDetail(props) { return filterSearch } - const timestampBegin = new Date(startDate).getTime() - const timestampEnd = new Date(endDate).getTime() + const numDays = startDate.until(endDate, { largestUnit: 'days' }).days + 1 - let ts = timestampEnd const columns = [] - while (ts >= timestampBegin) { - const d = new Date(ts) - const value = d.getUTCMonth() + 1 + '/' + d.getUTCDate() - columns.push(value) - ts -= msPerDay + let d = endDate + while (Temporal.PlainDate.compare(d, startDate) >= 0) { + columns.push(d.month + '/' + d.day) + d = d.subtract({ days: 1 }) } const rows = [] for (const job of data.jobs) { - const row = { - name: job.name, - results: [], + const buckets = Array.from({ length: numDays }, () => []) + + for (let i = 0; i < job.results.length; i++) { + const resultDate = Temporal.Instant.from(job.results[i].timestamp) + .toZonedDateTimeISO('UTC') + .toPlainDate() + const dayIndex = resultDate.until(endDate, { largestUnit: 'days' }).days + if (dayIndex < 0 || dayIndex >= numDays) continue + + buckets[dayIndex].push({ + name: job.name, + id: i, + failedTestNames: job.results[i].failedTestNames, + text: job.results[i].result, + prowLink: job.results[i].url, + className: 'result result-' + job.results[i].result, + }) } - for ( - let today = timestampBegin, tomorrow = timestampBegin + msPerDay; - today <= timestampEnd; - today += msPerDay, tomorrow += msPerDay - ) { - const day = [] - - for (let i = 0; i < job.results.length; i++) { - if ( - job.results[i].timestamp >= today && - job.results[i].timestamp < tomorrow - ) { - const result = {} - result.name = job.name - result.id = i - result.failedTestNames = job.results[i].failedTestNames - result.text = job.results[i].result - result.prowLink = job.results[i].url - result.className = 'result result-' + result.text - day.push(result) - i++ - } - } - - row.results.unshift(day) - } - - rows.push(row) + rows.push({ + name: job.name, + results: buckets, + }) } return (