This is an automated email from the ASF dual-hosted git repository. vogievetsky pushed a commit to branch segment_timeline2 in repository https://gitbox.apache.org/repos/asf/druid.git
commit 5c430de66ce982acef7bb87c5158b6ee81fd18d0 Author: Vadim Ogievetsky <[email protected]> AuthorDate: Tue Oct 29 16:16:42 2024 -0700 progress --- licenses.yaml | 76 ++-- web-console/package-lock.json | 119 ++--- web-console/package.json | 2 +- web-console/script/licenses | 1 + web-console/src/druid-models/index.ts | 1 + .../segment/segment.ts} | 35 +- .../date-floor-shift-ceil-utc.spec.ts | 169 +++++++ .../date-floor-shift-ceil.spec.ts | 181 ++++++++ .../date-floor-shift-ceil/date-floor-shift-ceil.ts | 296 ++++++++++++ web-console/src/utils/duration/duration.spec.ts | 505 +++++++++++++++++++++ web-console/src/utils/duration/duration.ts | 381 ++++++++++++++++ web-console/src/utils/index.tsx | 2 + .../views/datasources-view/datasources-view.tsx | 10 +- .../modules/multi-axis-chart-module.tsx | 12 +- .../explore-view/modules/time-chart-module.tsx | 17 +- .../src/views/explore-view/utils/duration.ts | 46 -- .../explore-view/utils/filter-pattern-helpers.ts | 8 +- .../explore-view/utils/get-auto-granularity.ts | 7 +- web-console/src/views/explore-view/utils/index.ts | 2 - .../explore-view/utils/snap-to-granularity.ts | 57 --- .../src/views/explore-view/utils/table-query.ts | 7 +- .../src/views/segments-view/segments-view.tsx | 60 +-- 22 files changed, 1663 insertions(+), 331 deletions(-) diff --git a/licenses.yaml b/licenses.yaml index 40305fc55c6..48c4ebd8390 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5224,6 +5224,16 @@ license_file_path: licenses/bin/@emotion-weak-memoize.MIT --- +name: "@flatten-js/interval-tree" +license_category: binary +module: web-console +license_name: MIT License +copyright: Alex Bol +version: 1.1.3 +license_file_path: licenses/bin/@flatten-js-interval-tree.MIT + +--- + name: "@fontsource/open-sans" license_category: binary module: web-console @@ -5234,6 +5244,15 @@ license_file_path: licenses/bin/@fontsource-open-sans.OFL --- +name: "@internationalized/date" +license_category: binary +module: web-console +license_name: Apache License version 2.0 +copyright: Adobe +version: 3.5.6 + +--- + name: "@popperjs/core" license_category: binary module: web-console @@ -5244,6 +5263,15 @@ license_file_path: licenses/bin/@popperjs-core.MIT --- +name: "@swc/helpers" +license_category: binary +module: web-console +license_name: Apache License version 2.0 +copyright: 강동윤 +version: 0.5.13 + +--- + name: "@types/parse-json" license_category: binary module: web-console @@ -5404,15 +5432,6 @@ license_file_path: licenses/bin/change-case.MIT --- -name: "chronoshift" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Vadim Ogievetsky -version: 0.10.0 - ---- - name: "classnames" license_category: binary module: web-console @@ -5801,16 +5820,6 @@ license_file_path: licenses/bin/has-flag.MIT --- -name: "has-own-prop" -license_category: binary -module: web-console -license_name: MIT License -copyright: Sindre Sorhus -version: 2.0.0 -license_file_path: licenses/bin/has-own-prop.MIT - ---- - name: "hasown" license_category: binary module: web-console @@ -5871,15 +5880,6 @@ license_file_path: licenses/bin/iconv-lite.MIT --- -name: "immutable-class" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Vadim Ogievetsky -version: 0.11.2 - ---- - name: "import-fresh" license_category: binary module: web-console @@ -6060,26 +6060,6 @@ license_file_path: licenses/bin/mime-types.MIT --- -name: "moment-timezone" -license_category: binary -module: web-console -license_name: MIT License -copyright: Tim Wood -version: 0.5.43 -license_file_path: licenses/bin/moment-timezone.MIT - ---- - -name: "moment" -license_category: binary -module: web-console -license_name: MIT License -copyright: Iskren Ivov Chernev -version: 2.29.4 -license_file_path: licenses/bin/moment.MIT - ---- - name: "no-case" license_category: binary module: web-console diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 4abbd266e0c..709bba0bf5b 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -17,9 +17,9 @@ "@druid-toolkit/query": "^0.22.23", "@flatten-js/interval-tree": "^1.1.3", "@fontsource/open-sans": "^5.0.30", + "@internationalized/date": "^3.5.6", "ace-builds": "~1.5.3", "axios": "^1.7.7", - "chronoshift": "^0.10.0", "classnames": "^2.2.6", "copy-to-clipboard": "^3.3.3", "d3-array": "^3.2.4", @@ -2449,6 +2449,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internationalized/date": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.6.tgz", + "integrity": "sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3480,6 +3489,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@swc/helpers": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", + "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -5809,16 +5827,6 @@ "node": ">=6.0" } }, - "node_modules/chronoshift": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz", - "integrity": "sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==", - "dependencies": { - "immutable-class": "^0.11.0", - "moment-timezone": "^0.5.26", - "tslib": "^2.3.1" - } - }, "node_modules/ci-info": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", @@ -8916,14 +8924,6 @@ "node": ">=4" } }, - "node_modules/has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -9361,15 +9361,6 @@ "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==", "dev": true }, - "node_modules/immutable-class": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz", - "integrity": "sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==", - "dependencies": { - "has-own-prop": "^2.0.0", - "tslib": "^2.3.1" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -12896,25 +12887,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.43", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", - "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/mrmime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", @@ -19827,6 +19799,14 @@ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true }, + "@internationalized/date": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.6.tgz", + "integrity": "sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -20626,6 +20606,14 @@ } } }, + "@swc/helpers": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", + "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "requires": { + "tslib": "^2.4.0" + } + }, "@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -22423,16 +22411,6 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, - "chronoshift": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz", - "integrity": "sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==", - "requires": { - "immutable-class": "^0.11.0", - "moment-timezone": "^0.5.26", - "tslib": "^2.3.1" - } - }, "ci-info": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", @@ -24662,11 +24640,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, - "has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==" - }, "has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -24994,15 +24967,6 @@ "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==", "dev": true }, - "immutable-class": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz", - "integrity": "sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==", - "requires": { - "has-own-prop": "^2.0.0", - "tslib": "^2.3.1" - } - }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -27569,19 +27533,6 @@ "minimist": "^1.2.5" } }, - "moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" - }, - "moment-timezone": { - "version": "0.5.43", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", - "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", - "requires": { - "moment": "^2.29.4" - } - }, "mrmime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", diff --git a/web-console/package.json b/web-console/package.json index 2580151bac1..22bb09d232c 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -58,9 +58,9 @@ "@druid-toolkit/query": "^0.22.23", "@flatten-js/interval-tree": "^1.1.3", "@fontsource/open-sans": "^5.0.30", + "@internationalized/date": "^3.5.6", "ace-builds": "~1.5.3", "axios": "^1.7.7", - "chronoshift": "^0.10.0", "classnames": "^2.2.6", "copy-to-clipboard": "^3.3.3", "d3-array": "^3.2.4", diff --git a/web-console/script/licenses b/web-console/script/licenses index 90f1420282a..f4e67862d01 100755 --- a/web-console/script/licenses +++ b/web-console/script/licenses @@ -193,6 +193,7 @@ checker.init( if (name === 'diff-match-patch') publisher = 'Google'; if (name === 'esutils') publisher = 'Yusuke Suzuki'; // https://github.com/estools/esutils#license if (name === 'echarts') publisher = 'Apache Software Foundation'; + if (name === '@internationalized/date') publisher = 'Adobe'; } if (!publisher) { diff --git a/web-console/src/druid-models/index.ts b/web-console/src/druid-models/index.ts index 82f14aab4e6..3e5c7062232 100644 --- a/web-console/src/druid-models/index.ts +++ b/web-console/src/druid-models/index.ts @@ -37,6 +37,7 @@ export * from './lookup-spec/lookup-spec'; export * from './metric-spec/metric-spec'; export * from './overlord-dynamic-config/overlord-dynamic-config'; export * from './query-context/query-context'; +export * from './segment/segment'; export * from './stages/stages'; export * from './supervisor-status/supervisor-status'; export * from './task/task'; diff --git a/web-console/src/views/explore-view/utils/duration.spec.ts b/web-console/src/druid-models/segment/segment.ts similarity index 54% rename from web-console/src/views/explore-view/utils/duration.spec.ts rename to web-console/src/druid-models/segment/segment.ts index 0d7e0473131..3860d4f5127 100644 --- a/web-console/src/views/explore-view/utils/duration.spec.ts +++ b/web-console/src/druid-models/segment/segment.ts @@ -16,24 +16,25 @@ * limitations under the License. */ -import { formatDuration } from './duration'; +import { Duration } from '../../utils'; -describe('formatDuration', () => { - it('works with 0', () => { - expect(formatDuration('PT0S')).toEqual('0 seconds'); - }); +export const START_OF_TIME_DATE = '-146136543-09-08T08:23:32.096Z'; +export const END_OF_TIME_DATE = '146140482-04-24T15:36:27.903Z'; - it('works with single span', () => { - expect(formatDuration('P1D')).toEqual('1 day'); - expect(formatDuration('PT1M')).toEqual('1 minute'); - }); +export function computeSegmentTimeSpan(start: string, end: string): string { + if (start === START_OF_TIME_DATE && end === END_OF_TIME_DATE) { + return 'All'; + } - it('works with single span (compact)', () => { - expect(formatDuration('PT1M', true)).toEqual('minute'); - }); + const startDate = new Date(start); + if (isNaN(startDate.valueOf())) { + return 'Invalid start'; + } - it('works with multiple spans', () => { - expect(formatDuration('PT2H30M15S')).toEqual('2 hours, 30 minutes, 15 seconds'); - expect(formatDuration('PT2H30M15S', true)).toEqual('2 hours, 30 minutes, 15 seconds'); - }); -}); + const endDate = new Date(end); + if (isNaN(endDate.valueOf())) { + return 'Invalid end'; + } + + return Duration.fromRange(startDate, endDate, 'Etc/UTC').getDescription(true); +} diff --git a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts new file mode 100755 index 00000000000..5ba63b04468 --- /dev/null +++ b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { shifters } from './date-floor-shift-ceil'; + +function pairwise<T>(array: T[], callback: (t1: T, t2: T) => void) { + for (let i = 0; i < array.length - 1; i++) { + callback(array[i], array[i + 1]); + } +} + +describe('floor, shift, ceil (UTC)', () => { + const tz = 'Etc/UTC'; + + it('moves seconds', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00Z'), + new Date('2012-11-04T00:00:03Z'), + new Date('2012-11-04T00:00:06Z'), + new Date('2012-11-04T00:00:09Z'), + new Date('2012-11-04T00:00:12Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.second.shift(d1, tz, 3)).toEqual(d2)); + }); + + it('rounds minutes', () => { + expect(shifters.minute.round(new Date('2012-11-04T00:29:00Z'), 15, tz)).toEqual( + new Date('2012-11-04T00:15:00Z'), + ); + + expect(shifters.minute.round(new Date('2012-11-04T00:29:00Z'), 4, tz)).toEqual( + new Date('2012-11-04T00:28:00Z'), + ); + }); + + it('moves minutes', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00Z'), + new Date('2012-11-04T00:03:00Z'), + new Date('2012-11-04T00:06:00Z'), + new Date('2012-11-04T00:09:00Z'), + new Date('2012-11-04T00:12:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.minute.shift(d1, tz, 3)).toEqual(d2)); + }); + + it('floors hour correctly', () => { + expect(shifters.hour.floor(new Date('2012-11-04T00:30:00Z'), tz)).toEqual( + new Date('2012-11-04T00:00:00Z'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T01:30:00Z'), tz)).toEqual( + new Date('2012-11-04T01:00:00Z'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T01:30:00Z'), tz)).toEqual( + new Date('2012-11-04T01:00:00Z'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T02:30:00Z'), tz)).toEqual( + new Date('2012-11-04T02:00:00Z'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T03:30:00Z'), tz)).toEqual( + new Date('2012-11-04T03:00:00Z'), + ); + }); + + it('moves hour', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00Z'), + new Date('2012-11-04T01:00:00Z'), + new Date('2012-11-04T02:00:00Z'), + new Date('2012-11-04T03:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('moves day', () => { + const dates: Date[] = [ + new Date('2012-11-03T00:00:00Z'), + new Date('2012-11-04T00:00:00Z'), + new Date('2012-11-05T00:00:00Z'), + new Date('2012-11-06T00:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.day.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('ceils day', () => { + let d1 = new Date('2014-12-11T22:11:57.469Z'); + let d2 = new Date('2014-12-12T00:00:00.000Z'); + expect(shifters.day.ceil(d1, tz)).toEqual(d2); + + d1 = new Date('2014-12-08T00:00:00.000Z'); + d2 = new Date('2014-12-08T00:00:00.000Z'); + expect(shifters.day.ceil(d1, tz)).toEqual(d2); + }); + + it('moves week', () => { + const dates: Date[] = [ + new Date('2012-10-29T00:00:00Z'), + new Date('2012-11-05T00:00:00Z'), + new Date('2012-11-12T00:00:00Z'), + new Date('2012-11-19T00:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.week.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('floors week correctly', () => { + let d1 = new Date('2014-12-11T22:11:57.469Z'); + let d2 = new Date('2014-12-08T00:00:00.000Z'); + expect(shifters.week.floor(d1, tz)).toEqual(d2); + + d1 = new Date('2014-12-07T12:11:57.469Z'); + d2 = new Date('2014-12-01T00:00:00.000Z'); + expect(shifters.week.floor(d1, tz)).toEqual(d2); + }); + + it('ceils week correctly', () => { + let d1 = new Date('2014-12-11T22:11:57.469Z'); + let d2 = new Date('2014-12-15T00:00:00.000Z'); + expect(shifters.week.ceil(d1, tz)).toEqual(d2); + + d1 = new Date('2014-12-07T12:11:57.469Z'); + d2 = new Date('2014-12-08T00:00:00.000Z'); + expect(shifters.week.ceil(d1, tz)).toEqual(d2); + }); + + it('moves month', () => { + const dates: Date[] = [ + new Date('2012-11-01T00:00:00Z'), + new Date('2012-12-01T00:00:00Z'), + new Date('2013-01-01T00:00:00Z'), + new Date('2013-02-01T00:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.month.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('shifts month on the 31st', () => { + const d1 = new Date('2016-03-31T00:00:00.000Z'); + const d2 = new Date('2016-05-01T00:00:00.000Z'); + expect(shifters.month.shift(d1, tz, 1)).toEqual(d2); + }); + + it('moves year', () => { + const dates: Date[] = [ + new Date('2010-01-01T00:00:00Z'), + new Date('2011-01-01T00:00:00Z'), + new Date('2012-01-01T00:00:00Z'), + new Date('2013-01-01T00:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.year.shift(d1, tz, 1)).toEqual(d2)); + }); +}); diff --git a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts new file mode 100755 index 00000000000..1612c9ce579 --- /dev/null +++ b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { shifters } from './date-floor-shift-ceil'; + +function pairwise<T>(array: T[], callback: (t1: T, t2: T) => void) { + for (let i = 0; i < array.length - 1; i++) { + callback(array[i], array[i + 1]); + } +} + +describe('floor/shift/ceil', () => { + const tz = 'America/Los_Angeles'; + + it('shifts seconds', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00-07:00'), + new Date('2012-11-04T00:00:03-07:00'), + new Date('2012-11-04T00:00:06-07:00'), + new Date('2012-11-04T00:00:09-07:00'), + new Date('2012-11-04T00:00:12-07:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.second.shift(d1, tz, 3)).toEqual(d2)); + }); + + it('shifts minutes', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00-07:00'), + new Date('2012-11-04T00:03:00-07:00'), + new Date('2012-11-04T00:06:00-07:00'), + new Date('2012-11-04T00:09:00-07:00'), + new Date('2012-11-04T00:12:00-07:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.minute.shift(d1, tz, 3)).toEqual(d2)); + }); + + it('floors hour correctly', () => { + expect(shifters.hour.floor(new Date('2012-11-04T00:30:00-07:00'), tz)).toEqual( + new Date('2012-11-04T00:00:00-07:00'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T01:30:00-07:00'), tz)).toEqual( + new Date('2012-11-04T01:00:00-07:00'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T01:30:00-08:00'), tz)).toEqual( + new Date('2012-11-04T01:00:00-07:00'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T02:30:00-08:00'), tz)).toEqual( + new Date('2012-11-04T02:00:00-08:00'), + ); + + expect(shifters.hour.floor(new Date('2012-11-04T03:30:00-08:00'), tz)).toEqual( + new Date('2012-11-04T03:00:00-08:00'), + ); + }); + + it('shifting 24 hours over DST is not the same as shifting a day', () => { + const start = new Date('2012-11-04T07:00:00Z'); + + const shift1Day = shifters.day.shift(start, tz, 1); + const shift24Hours = shifters.hour.shift(start, tz, 24); + + expect(shift1Day).toEqual(new Date('2012-11-05T08:00:00Z')); + expect(shift24Hours).toEqual(new Date('2012-11-05T07:00:00Z')); + }); + + it('shifts hour over DST 1', () => { + const dates: Date[] = [ + new Date('2012-11-04T00:00:00-07:00'), + new Date('2012-11-04T08:00:00Z'), + new Date('2012-11-04T09:00:00Z'), + new Date('2012-11-04T10:00:00Z'), + new Date('2012-11-04T11:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('floors hour over DST 1', () => { + expect(shifters.hour.floor(new Date('2012-11-04T00:05:00-07:00'), tz)).toEqual( + new Date('2012-11-04T00:00:00-07:00'), + ); + expect(shifters.hour.floor(new Date('2012-11-04T01:05:00-07:00'), tz)).toEqual( + new Date('2012-11-04T01:00:00-07:00'), + ); + expect(shifters.hour.floor(new Date('2012-11-04T02:05:00-07:00'), tz)).toEqual( + new Date('2012-11-04T01:00:00-07:00'), + ); + expect(shifters.hour.floor(new Date('2012-11-04T03:05:00-07:00'), tz)).toEqual( + new Date('2012-11-04T03:00:00-07:00'), + ); + }); + + it('shifts hour over DST 2', () => { + // "2018-03-11T09:00:00Z" + const dates: Date[] = [ + new Date('2018-03-11T01:00:00-07:00'), + new Date('2018-03-11T09:00:00Z'), + new Date('2018-03-11T10:00:00Z'), + new Date('2018-03-11T11:00:00Z'), + new Date('2018-03-11T12:00:00Z'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('shifts day over DST', () => { + const dates: Date[] = [ + new Date('2012-11-03T00:00:00-07:00'), + new Date('2012-11-04T00:00:00-07:00'), + new Date('2012-11-05T00:00:00-08:00'), + new Date('2012-11-06T00:00:00-08:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.day.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('shifts week over DST', () => { + const dates: Date[] = [ + new Date('2012-10-29T00:00:00-07:00'), + new Date('2012-11-05T00:00:00-08:00'), + new Date('2012-11-12T00:00:00-08:00'), + new Date('2012-11-19T00:00:00-08:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.week.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('floors week correctly', () => { + let d1 = new Date('2014-12-11T22:11:57.469Z'); + let d2 = new Date('2014-12-08T08:00:00.000Z'); + expect(shifters.week.floor(d1, tz)).toEqual(d2); + + d1 = new Date('2014-12-07T12:11:57.469Z'); + d2 = new Date('2014-12-01T08:00:00.000Z'); + expect(shifters.week.floor(d1, tz)).toEqual(d2); + }); + + it('ceils week correctly', () => { + let d1 = new Date('2014-12-11T22:11:57.469Z'); + let d2 = new Date('2014-12-15T08:00:00.000Z'); + expect(shifters.week.ceil(d1, tz)).toEqual(d2); + + d1 = new Date('2014-12-07T12:11:57.469Z'); + d2 = new Date('2014-12-08T08:00:00.000Z'); + expect(shifters.week.ceil(d1, tz)).toEqual(d2); + }); + + it('shifts month over DST', () => { + const dates: Date[] = [ + new Date('2012-11-01T00:00:00-07:00'), + new Date('2012-12-01T00:00:00-08:00'), + new Date('2013-01-01T00:00:00-08:00'), + new Date('2013-02-01T00:00:00-08:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.month.shift(d1, tz, 1)).toEqual(d2)); + }); + + it('shifts year', () => { + const dates: Date[] = [ + new Date('2010-01-01T00:00:00-08:00'), + new Date('2011-01-01T00:00:00-08:00'), + new Date('2012-01-01T00:00:00-08:00'), + new Date('2013-01-01T00:00:00-08:00'), + ]; + pairwise(dates, (d1, d2) => expect(shifters.year.shift(d1, tz, 1)).toEqual(d2)); + }); +}); diff --git a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts new file mode 100755 index 00000000000..3306b05267d --- /dev/null +++ b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fromDate, startOfWeek } from '@internationalized/date'; + +export type AlignFn = (dt: Date, tz: string) => Date; + +export type ShiftFn = (dt: Date, tz: string, step: number) => Date; + +export type RoundFn = (dt: Date, roundTo: number, tz: string) => Date; + +export interface TimeShifterNoCeil { + canonicalLength: number; + siblings?: number; + floor: AlignFn; + round: RoundFn; + shift: ShiftFn; +} + +export interface TimeShifter extends TimeShifterNoCeil { + ceil: AlignFn; +} + +function isUTC(tz: string): boolean { + return tz === 'Etc/UTC'; +} + +function adjustDay(day: number): number { + return (day + 6) % 7; +} + +function floorTo(n: number, roundTo: number): number { + return Math.floor(n / roundTo) * roundTo; +} + +function timeShifterFiller(tm: TimeShifterNoCeil): TimeShifter { + const { floor, shift } = tm; + return { + ...tm, + ceil: (dt: Date, tz: string) => { + const floored = floor(dt, tz); + if (floored.valueOf() === dt.valueOf()) return dt; // Just like ceil(3) is 3 and not 4 + return shift(floored, tz, 1); + }, + }; +} + +export const second = timeShifterFiller({ + canonicalLength: 1000, + siblings: 60, + floor: (dt, _tz) => { + // Seconds do not actually need a timezone because all timezones align on seconds... for now... + dt = new Date(dt.valueOf()); + dt.setUTCMilliseconds(0); + return dt; + }, + round: (dt, roundTo, _tz) => { + const cur = dt.getUTCSeconds(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCSeconds(adj); + return dt; + }, + shift: (dt, _tz, step) => { + dt = new Date(dt.valueOf()); + dt.setUTCSeconds(dt.getUTCSeconds() + step); + return dt; + }, +}); + +export const minute = timeShifterFiller({ + canonicalLength: 60000, + siblings: 60, + floor: (dt, _tz) => { + // Minutes do not actually need a timezone because all timezones align on minutes... for now... + dt = new Date(dt.valueOf()); + dt.setUTCSeconds(0, 0); + return dt; + }, + round: (dt, roundTo, _tz) => { + const cur = dt.getUTCMinutes(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCMinutes(adj); + return dt; + }, + shift: (dt, _tz, step) => { + dt = new Date(dt.valueOf()); + dt.setUTCMinutes(dt.getUTCMinutes() + step); + return dt; + }, +}); + +// Movement by hour is tz independent because in every timezone an hour is 60 min +function hourMove(dt: Date, _tz: string, step: number) { + dt = new Date(dt.valueOf()); + dt.setUTCHours(dt.getUTCHours() + step); + return dt; +} + +export const hour = timeShifterFiller({ + canonicalLength: 3600000, + siblings: 24, + floor: (dt, tz) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCMinutes(0, 0, 0); + return dt; + } else { + return fromDate(dt, tz).set({ second: 0, minute: 0, millisecond: 0 }).toDate(); + } + }, + round: (dt, roundTo, tz) => { + if (isUTC(tz)) { + const cur = dt.getUTCHours(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCHours(adj); + } else { + const cur = fromDate(dt, tz).hour; + const adj = floorTo(cur, roundTo); + if (cur !== adj) return hourMove(dt, tz, adj - cur); + } + return dt; + }, + shift: hourMove, +}); + +export const day = timeShifterFiller({ + canonicalLength: 24 * 3600000, + floor: (dt, tz) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCHours(0, 0, 0, 0); + return dt; + } else { + return fromDate(dt, tz).set({ hour: 0, second: 0, minute: 0, millisecond: 0 }).toDate(); + } + }, + shift: (dt, tz, step) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCDate(dt.getUTCDate() + step); + return dt; + } else { + return fromDate(dt, tz).add({ days: step }).toDate(); + } + }, + round: () => { + throw new Error('missing day round'); + }, +}); + +export const week = timeShifterFiller({ + canonicalLength: 7 * 24 * 3600000, + floor: (dt, tz) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCHours(0, 0, 0, 0); + dt.setUTCDate(dt.getUTCDate() - adjustDay(dt.getUTCDay())); + } else { + const zd = fromDate(dt, tz); + return startOfWeek( + zd.set({ hour: 0, second: 0, minute: 0, millisecond: 0 }), + 'fr-FR', // We want the week to start on Monday + ).toDate(); + } + return dt; + }, + shift: (dt, tz, step) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCDate(dt.getUTCDate() + step * 7); + return dt; + } else { + return fromDate(dt, tz).add({ weeks: step }).toDate(); + } + }, + round: () => { + throw new Error('missing week round'); + }, +}); + +function monthShift(dt: Date, tz: string, step: number) { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCMonth(dt.getUTCMonth() + step); + return dt; + } else { + return fromDate(dt, tz).add({ months: step }).toDate(); + } +} + +export const month = timeShifterFiller({ + canonicalLength: 30 * 24 * 3600000, + siblings: 12, + floor: (dt, tz) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCHours(0, 0, 0, 0); + dt.setUTCDate(1); + return dt; + } else { + return fromDate(dt, tz) + .set({ day: 1, hour: 0, second: 0, minute: 0, millisecond: 0 }) + .toDate(); + } + }, + round: (dt, roundTo, tz) => { + if (isUTC(tz)) { + const cur = dt.getUTCMonth(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCMonth(adj); + } else { + const cur = fromDate(dt, tz).month - 1; // Needs to be zero indexed + const adj = floorTo(cur, roundTo); + if (cur !== adj) return monthShift(dt, tz, adj - cur); + } + return dt; + }, + shift: monthShift, +}); + +function yearShift(dt: Date, tz: string, step: number) { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCFullYear(dt.getUTCFullYear() + step); + return dt; + } else { + return fromDate(dt, tz).add({ years: step }).toDate(); + } +} + +export const year = timeShifterFiller({ + canonicalLength: 365 * 24 * 3600000, + siblings: 1000, + floor: (dt, tz) => { + if (isUTC(tz)) { + dt = new Date(dt.valueOf()); + dt.setUTCHours(0, 0, 0, 0); + dt.setUTCMonth(0, 1); + return dt; + } else { + return fromDate(dt, tz) + .set({ month: 1, day: 1, hour: 0, second: 0, minute: 0, millisecond: 0 }) + .toDate(); + } + }, + round: (dt, roundTo, tz) => { + if (isUTC(tz)) { + const cur = dt.getUTCFullYear(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCFullYear(adj); + } else { + const cur = fromDate(dt, tz).year; + const adj = floorTo(cur, roundTo); + if (cur !== adj) return yearShift(dt, tz, adj - cur); + } + return dt; + }, + shift: yearShift, +}); + +export interface Shifters { + second: TimeShifter; + minute: TimeShifter; + hour: TimeShifter; + day: TimeShifter; + week: TimeShifter; + month: TimeShifter; + year: TimeShifter; + + [key: string]: TimeShifter; +} + +export const shifters: Shifters = { + second, + minute, + hour, + day, + week, + month, + year, +}; diff --git a/web-console/src/utils/duration/duration.spec.ts b/web-console/src/utils/duration/duration.spec.ts new file mode 100755 index 00000000000..8b20ac0a6b2 --- /dev/null +++ b/web-console/src/utils/duration/duration.spec.ts @@ -0,0 +1,505 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from './duration'; + +describe('Duration', () => { + const TZ_LA = 'America/Los_Angeles'; + const TZ_JUNEAU = 'America/Juneau'; + + describe('errors', () => { + it('throws error if invalid duration', () => { + expect(() => new Duration('')).toThrow("Can not parse duration ''"); + + expect(() => new Duration('P00')).toThrow("Can not parse duration 'P00'"); + + expect(() => new Duration('P')).toThrow('Duration can not be empty'); + + expect(() => new Duration('P0YT0H')).toThrow('Duration can not be empty'); + + expect(() => new Duration('P0W').shift(new Date(), TZ_LA)).toThrow( + 'Duration can not have empty weeks', + ); + + expect(() => new Duration('P0Y0MT0H0M0S').shift(new Date(), TZ_LA)).toThrow( + 'Duration can not be empty', + ); + }); + + it('throws error if fromJS is not given a string', () => { + expect(() => new Duration(new Date() as any)).toThrow('Duration can not be empty'); + }); + }); + + describe('#toString', () => { + it('gives back the correct string', () => { + let durationStr: string; + + durationStr = 'P3Y'; + expect(new Duration(durationStr).toString()).toEqual(durationStr); + + durationStr = 'P2W'; + expect(new Duration(durationStr).toString()).toEqual(durationStr); + + durationStr = 'PT5H'; + expect(new Duration(durationStr).toString()).toEqual(durationStr); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).toString()).toEqual(durationStr); + }); + + it('eliminates 0', () => { + expect(new Duration('P0DT15H').toString()).toEqual('PT15H'); + }); + }); + + describe('fromCanonicalLength', () => { + it('handles zero', () => { + expect(() => { + Duration.fromCanonicalLength(0); + }).toThrow('length must be positive'); + }); + + it('works 1', () => { + expect(Duration.fromCanonicalLength(86400000).toString()).toEqual('P1D'); + }); + + it('works 2', () => { + const len = + new Date('2018-03-01T00:00:00Z').valueOf() - new Date('2016-02-22T00:00:00Z').valueOf(); + expect(Duration.fromCanonicalLength(len).toString()).toEqual('P2Y8D'); + }); + + it('works 3', () => { + const len = + new Date('2018-09-15T00:00:00Z').valueOf() - new Date('2018-09-04T00:00:00Z').valueOf(); + expect(Duration.fromCanonicalLength(len).toString()).toEqual('P11D'); + }); + + it('works with months', () => { + expect(Duration.fromCanonicalLength(2592000000).toString()).toEqual('P1M'); + expect(Duration.fromCanonicalLength(2678400000).toString()).toEqual('P1M1D'); + }); + + it('works without months', () => { + expect(Duration.fromCanonicalLength(2592000000, true).toString()).toEqual('P30D'); + expect(Duration.fromCanonicalLength(2678400000, true).toString()).toEqual('P31D'); + }); + }); + + describe('construct from span', () => { + it('parses days over DST', () => { + expect( + Duration.fromRange( + new Date('2012-10-29T00:00:00-07:00'), + new Date('2012-11-05T00:00:00-08:00'), + TZ_LA, + ).toString(), + ).toEqual('P7D'); + + expect( + Duration.fromRange( + new Date('2012-10-29T00:00:00-07:00'), + new Date('2012-11-12T00:00:00-08:00'), + TZ_LA, + ).toString(), + ).toEqual('P14D'); + }); + + it('parses complex case', () => { + expect( + Duration.fromRange( + new Date('2012-10-29T00:00:00-07:00'), + new Date(new Date('2012-11-05T00:00:00-08:00').valueOf() - 1000), + TZ_LA, + ).toString(), + ).toEqual('P6DT24H59M59S'); + + expect( + Duration.fromRange( + new Date('2012-01-01T00:00:00-08:00'), + new Date('2013-03-04T04:05:06-08:00'), + TZ_LA, + ).toString(), + ).toEqual('P1Y2M3DT4H5M6S'); + }); + }); + + describe('#isFloorable', () => { + const floorable = 'P1Y P5Y P10Y P100Y P1M P2M P3M P4M P1D'.split(' '); + for (const v of floorable) { + it(`works on floorable ${v}`, () => { + expect(new Duration(v).isFloorable()).toEqual(true); + }); + } + + const unfloorable = 'P1Y1M P5M P2D P3D'.split(' '); + for (const v of unfloorable) { + it(`works on not floorable ${v}`, () => { + expect(new Duration(v).isFloorable()).toEqual(false); + }); + } + }); + + describe('#floor', () => { + it('throws error if complex duration', () => { + expect(() => new Duration('P1Y2D').floor(new Date(), TZ_LA)).toThrow( + 'Can not floor on a complex duration', + ); + + expect(() => new Duration('P3DT15H').floor(new Date(), TZ_LA)).toThrow( + 'Can not floor on a complex duration', + ); + + expect(() => new Duration('PT5H').floor(new Date(), TZ_LA)).toThrow( + 'Can not floor on a hour duration that does not divide into 24', + ); + }); + + it('works for year', () => { + const p1y = new Duration('P1Y'); + expect(p1y.floor(new Date('2013-09-29T01:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-01-01T00:00:00.000-08:00'), + ); + }); + + it('works for PT2M', () => { + const pt2h = new Duration('PT2M'); + expect(pt2h.floor(new Date('2013-09-29T03:03:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-09-29T03:02:00.000-07:00'), + ); + }); + + it('works for P2H', () => { + const pt2h = new Duration('PT2H'); + expect(pt2h.floor(new Date('2013-09-29T03:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-09-29T02:00:00.000-07:00'), + ); + }); + + it('works for PT12H', () => { + const pt12h = new Duration('PT12H'); + expect(pt12h.floor(new Date('2015-09-12T13:05:00-08:00'), TZ_JUNEAU)).toEqual( + new Date('2015-09-12T12:00:00-08:00'), + ); + }); + + it('works for P1W', () => { + const p1w = new Duration('P1W'); + + expect(p1w.floor(new Date('2013-09-29T01:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-09-23T07:00:00.000Z'), + ); + + expect(p1w.floor(new Date('2013-10-03T01:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-09-30T00:00:00.000-07:00'), + ); + }); + + it('works for P3M', () => { + const p3m = new Duration('P3M'); + expect(p3m.floor(new Date('2013-09-29T03:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-07-01T00:00:00.000-07:00'), + ); + + expect(p3m.floor(new Date('2013-02-29T03:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2013-01-01T00:00:00.000-08:00'), + ); + }); + + it('works for P4Y', () => { + const p4y = new Duration('P4Y'); + expect(p4y.floor(new Date('2013-09-29T03:02:03.456-07:00'), TZ_LA)).toEqual( + new Date('2012-01-01T00:00:00.000-08:00'), + ); + }); + }); + + describe('#shift', () => { + it('works for weeks', () => { + let p1w = new Duration('P1W'); + expect(p1w.shift(new Date('2012-10-29T00:00:00-07:00'), TZ_LA)).toEqual( + new Date('2012-11-05T00:00:00-08:00'), + ); + + p1w = new Duration('P1W'); + expect(p1w.shift(new Date('2012-10-29T00:00:00-07:00'), TZ_LA, 2)).toEqual( + new Date('2012-11-12T00:00:00-08:00'), + ); + + const p2w = new Duration('P2W'); + expect(p2w.shift(new Date('2012-10-29T05:16:17-07:00'), TZ_LA)).toEqual( + new Date('2012-11-12T05:16:17-08:00'), + ); + }); + + it('works for general complex case', () => { + const pComplex = new Duration('P1Y2M3DT4H5M6S'); + expect(pComplex.shift(new Date('2012-01-01T00:00:00-08:00'), TZ_LA)).toEqual( + new Date('2013-03-04T04:05:06-08:00'), + ); + }); + }); + + describe('#materialize', () => { + it('works for weeks', () => { + const p1w = new Duration('P1W'); + + expect( + p1w.materialize( + new Date('2012-10-29T00:00:00-07:00'), + new Date('2012-12-01T00:00:00-08:00'), + TZ_LA, + ), + ).toEqual([ + new Date('2012-10-29T07:00:00.000Z'), + new Date('2012-11-05T08:00:00.000Z'), + new Date('2012-11-12T08:00:00.000Z'), + new Date('2012-11-19T08:00:00.000Z'), + new Date('2012-11-26T08:00:00.000Z'), + ]); + + expect( + p1w.materialize( + new Date('2012-10-29T00:00:00-07:00'), + new Date('2012-12-01T00:00:00-08:00'), + TZ_LA, + 2, + ), + ).toEqual([ + new Date('2012-10-29T07:00:00.000Z'), + new Date('2012-11-12T08:00:00.000Z'), + new Date('2012-11-26T08:00:00.000Z'), + ]); + }); + }); + + describe('#isAligned', () => { + it('works for weeks', () => { + const p1w = new Duration('P1W'); + expect(p1w.isAligned(new Date('2012-10-29T00:00:00-07:00'), TZ_LA)).toEqual(true); + expect(p1w.isAligned(new Date('2012-10-29T00:00:00-07:00'), 'Etc/UTC')).toEqual(false); + }); + }); + + describe('#dividesBy', () => { + const divisible = 'P5Y/P1Y P1D/P1D P1M/P1D P1W/P1D P1D/PT6H PT3H/PT1H'.split(' '); + for (const v of divisible) { + it(`works for ${v} (true)`, () => { + const p = v.split('/'); + expect(new Duration(p[0]).dividesBy(new Duration(p[1]))).toEqual(true); + }); + } + + const undivisible = 'P1D/P1M PT5H/PT1H'.split(' '); + for (const v of undivisible) { + it(`works for ${v} (false)`, () => { + const p = v.split('/'); + expect(new Duration(p[0]).dividesBy(new Duration(p[1]))).toEqual(false); + }); + } + }); + + describe('#getCanonicalLength', () => { + it('gives back the correct canonical length', () => { + let durationStr: string; + + durationStr = 'P3Y'; + expect(new Duration(durationStr).getCanonicalLength()).toEqual(94608000000); + + durationStr = 'P2W'; + expect(new Duration(durationStr).getCanonicalLength()).toEqual(1209600000); + + durationStr = 'PT5H'; + expect(new Duration(durationStr).getCanonicalLength()).toEqual(18000000); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getCanonicalLength()).toEqual(313200000); + }); + }); + + describe('#add()', () => { + it('works with a simple duration', () => { + const d1 = new Duration('P1D'); + const d2 = new Duration('P1D'); + + expect(d1.add(d2).toString()).toEqual('P2D'); + }); + + it('works with heterogeneous spans', () => { + const d1 = new Duration('P1D'); + const d2 = new Duration('P1Y'); + + expect(d1.add(d2).toString()).toEqual('P1Y1D'); + }); + + it('works with weeks', () => { + let d1 = new Duration('P1W'); + let d2 = new Duration('P2W'); + expect(d1.add(d2).toString()).toEqual('P3W'); + + d1 = new Duration('P6D'); + d2 = new Duration('P1D'); + expect(d1.add(d2).toString()).toEqual('P1W'); + }); + }); + + describe('#subtract()', () => { + it('works with a simple duration', () => { + const d1 = new Duration('P1DT2H'); + const d2 = new Duration('PT1H'); + + expect(d1.subtract(d2).toString()).toEqual('P1DT1H'); + }); + + it('works with a less simple duration', () => { + const d1 = new Duration('P1D'); + const d2 = new Duration('PT1H'); + + expect(d1.subtract(d2).toString()).toEqual('PT23H'); + }); + + it('works with weeks', () => { + const d1 = new Duration('P1W'); + const d2 = new Duration('P1D'); + + expect(d1.subtract(d2).toString()).toEqual('P6D'); + }); + + it('throws an error if result is going to be negative', () => { + const d1 = new Duration('P1D'); + const d2 = new Duration('P2D'); + + expect(() => d1.subtract(d2)).toThrow(); + }); + }); + + describe('#multiply()', () => { + it('works with a simple duration', () => { + const d = new Duration('P1D'); + expect(d.multiply(5).toString()).toEqual('P5D'); + }); + + it('works with a less simple duration', () => { + const d = new Duration('P1DT2H'); + expect(d.multiply(2).toString()).toEqual('P2DT4H'); + }); + + it('works with weeks', () => { + const d = new Duration('P1W'); + expect(d.multiply(5).toString()).toEqual('P5W'); + }); + + it('throws an error if result is going to be negative', () => { + const d = new Duration('P1D'); + expect(() => d.multiply(-1)).toThrow('Multiplier must be positive non-zero'); + }); + + it('gets description properly', () => { + const d = new Duration('P2D'); + expect(d.multiply(2).getDescription(true)).toEqual('4 Days'); + }); + }); + + describe('#getDescription()', () => { + it('gives back the correct description', () => { + let durationStr: string; + + durationStr = 'P1D'; + expect(new Duration(durationStr).getDescription()).toEqual('day'); + + durationStr = 'P1DT2H'; + expect(new Duration(durationStr).getDescription()).toEqual('1 day, 2 hours'); + + durationStr = 'P3Y'; + expect(new Duration(durationStr).getDescription()).toEqual('3 years'); + + durationStr = 'P2W'; + expect(new Duration(durationStr).getDescription()).toEqual('2 weeks'); + + durationStr = 'PT5H'; + expect(new Duration(durationStr).getDescription()).toEqual('5 hours'); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getDescription()).toEqual('3 days, 15 hours'); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getDescription(true)).toEqual('3 Days, 15 Hours'); + }); + }); + + describe('#getSingleSpan()', () => { + it('gives back the correct span', () => { + let durationStr: string; + + durationStr = 'P1D'; + expect(new Duration(durationStr).getSingleSpan()).toEqual('day'); + + durationStr = 'P3Y'; + expect(new Duration(durationStr).getSingleSpan()).toEqual('year'); + + durationStr = 'P2W'; + expect(new Duration(durationStr).getSingleSpan()).toEqual('week'); + + durationStr = 'PT5H'; + expect(new Duration(durationStr).getSingleSpan()).toEqual('hour'); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getSingleSpan()).toBeUndefined(); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getSingleSpan()).toBeUndefined(); + }); + }); + + describe('#getSingleSpanValue()', () => { + it('gives back the correct span value', () => { + let durationStr: string; + + durationStr = 'P1D'; + expect(new Duration(durationStr).getSingleSpanValue()).toEqual(1); + + durationStr = 'P3Y'; + expect(new Duration(durationStr).getSingleSpanValue()).toEqual(3); + + durationStr = 'P2W'; + expect(new Duration(durationStr).getSingleSpanValue()).toEqual(2); + + durationStr = 'PT5H'; + expect(new Duration(durationStr).getSingleSpanValue()).toEqual(5); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getSingleSpanValue()).toBeUndefined(); + + durationStr = 'P3DT15H'; + expect(new Duration(durationStr).getSingleSpanValue()).toBeUndefined(); + }); + }); + + describe('#limitToDays', () => { + it('works', () => { + expect(new Duration('P6D').limitToDays().toString()).toEqual('P6D'); + + expect(new Duration('P1M').limitToDays().toString()).toEqual('P30D'); + + expect(new Duration('P1Y').limitToDays().toString()).toEqual('P365D'); + + expect(new Duration('P1Y2M').limitToDays().toString()).toEqual('P425D'); + }); + }); +}); diff --git a/web-console/src/utils/duration/duration.ts b/web-console/src/utils/duration/duration.ts new file mode 100755 index 00000000000..b93dab175ad --- /dev/null +++ b/web-console/src/utils/duration/duration.ts @@ -0,0 +1,381 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { second, shifters } from '../date-floor-shift-ceil/date-floor-shift-ceil'; +import { capitalizeFirst, pluralIfNeeded } from '../general'; + +const SPANS_WITH_WEEK = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; +const SPANS_WITHOUT_WEEK = ['year', 'month', 'day', 'hour', 'minute', 'second']; +const SPANS_WITHOUT_WEEK_OR_MONTH = ['year', 'day', 'hour', 'minute', 'second']; +const SPANS_UP_TO_DAY = ['day', 'hour', 'minute', 'second']; + +export interface DurationValue { + year?: number; + month?: number; + week?: number; + day?: number; + hour?: number; + minute?: number; + second?: number; + + // Indexable + [span: string]: number | undefined; +} + +const periodWeekRegExp = /^P(\d+)W$/; +const periodRegExp = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; +// P (year ) (month ) (day ) T(hour ) (minute ) (second ) + +function getSpansFromString(durationStr: string): DurationValue { + const spans: DurationValue = {}; + let matches: RegExpExecArray | null; + if ((matches = periodWeekRegExp.exec(durationStr))) { + spans.week = Number(matches[1]); + if (!spans.week) throw new Error('Duration can not have empty weeks'); + } else if ((matches = periodRegExp.exec(durationStr))) { + const nums = matches.map(Number); + for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { + const span = SPANS_WITHOUT_WEEK[i]; + const value = nums[i + 1]; + if (value) spans[span] = value; + } + } else { + throw new Error("Can not parse duration '" + durationStr + "'"); + } + return spans; +} + +function getSpansFromStartEnd(start: Date, end: Date, timezone: string): DurationValue { + start = second.floor(start, timezone); + end = second.floor(end, timezone); + if (end <= start) throw new Error('start must come before end'); + + const spans: DurationValue = {}; + let iterator: Date = start; + for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { + const span = SPANS_WITHOUT_WEEK[i]; + let spanCount = 0; + + // Shortcut + const length = end.valueOf() - iterator.valueOf(); + const canonicalLength: number = shifters[span].canonicalLength; + if (length < canonicalLength / 4) continue; + const numberToFit = Math.min(0, Math.floor(length / canonicalLength) - 1); + let iteratorMove: Date; + if (numberToFit > 0) { + // try to skip by numberToFit + iteratorMove = shifters[span].shift(iterator, timezone, numberToFit); + if (iteratorMove <= end) { + spanCount += numberToFit; + iterator = iteratorMove; + } + } + + while (true) { + iteratorMove = shifters[span].shift(iterator, timezone, 1); + if (iteratorMove <= end) { + iterator = iteratorMove; + spanCount++; + } else { + break; + } + } + + if (spanCount) { + spans[span] = spanCount; + } + } + return spans; +} + +function removeZeros(spans: DurationValue): DurationValue { + const newSpans: DurationValue = {}; + for (let i = 0; i < SPANS_WITH_WEEK.length; i++) { + const span = SPANS_WITH_WEEK[i]; + if (Number(spans[span]) > 0) { + newSpans[span] = spans[span]; + } + } + return newSpans; +} + +function fitIntoSpans(length: number, spansToCheck: string[]): Record<string, number> { + const spans: Record<string, number> = {}; + + let lengthLeft = length; + for (let i = 0; i < spansToCheck.length; i++) { + const span = spansToCheck[i]; + const spanLength = shifters[span].canonicalLength; + const count = Math.floor(lengthLeft / spanLength); + + if (count) { + lengthLeft -= spanLength * count; + spans[span] = count; + } + } + + return spans; +} + +/** + * Represents an ISO duration like P1DT3H + */ +export class Duration { + public readonly singleSpan?: string; + public readonly spans: Readonly<DurationValue>; + + static parse(durationStr: string): Duration { + if (typeof durationStr !== 'string') throw new TypeError('Duration JS must be a string'); + return new Duration(getSpansFromString(durationStr)); + } + + static fromCanonicalLength(length: number, skipMonths = false): Duration { + if (length <= 0) throw new Error('length must be positive'); + let spans = fitIntoSpans(length, skipMonths ? SPANS_WITHOUT_WEEK_OR_MONTH : SPANS_WITHOUT_WEEK); + + if ( + length % shifters['week'].canonicalLength === 0 && // Weeks fits + (Object.keys(spans).length > 1 || // We already have a more complex span + spans['day']) // or... we only have days and it might be simpler to express as weeks + ) { + spans = { week: length / shifters['week'].canonicalLength }; + } + + return new Duration(spans); + } + + static fromCanonicalLengthUpToDays(length: number): Duration { + if (length <= 0) throw new Error('length must be positive'); + return new Duration(fitIntoSpans(length, SPANS_UP_TO_DAY)); + } + + static fromRange(start: Date, end: Date, timezone: string): Duration { + return new Duration(getSpansFromStartEnd(start, end, timezone)); + } + + constructor(spans: DurationValue | string) { + const effectiveSpans: DurationValue = + typeof spans === 'string' ? getSpansFromString(spans) : removeZeros(spans); + + const usedSpans = Object.keys(effectiveSpans); + if (!usedSpans.length) throw new Error('Duration can not be empty'); + if (usedSpans.length === 1) { + this.singleSpan = usedSpans[0]; + } else if (effectiveSpans.week) { + throw new Error("Can not mix 'week' and other spans"); + } + this.spans = effectiveSpans; + } + + public toString() { + const strArr: string[] = ['P']; + const spans = this.spans; + if (spans.week) { + strArr.push(String(spans.week), 'W'); + } else { + let addedT = false; + for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) { + const span = SPANS_WITHOUT_WEEK[i]; + const value = spans[span]; + if (!value) continue; + if (!addedT && i >= 3) { + strArr.push('T'); + addedT = true; + } + strArr.push(String(value), span[0].toUpperCase()); + } + } + return strArr.join(''); + } + + public add(duration: Duration): Duration { + return Duration.fromCanonicalLength(this.getCanonicalLength() + duration.getCanonicalLength()); + } + + public subtract(duration: Duration): Duration { + const newCanonicalDuration = this.getCanonicalLength() - duration.getCanonicalLength(); + if (newCanonicalDuration < 0) throw new Error('A duration can not be negative.'); + return Duration.fromCanonicalLength(newCanonicalDuration); + } + + public multiply(multiplier: number): Duration { + if (multiplier <= 0) throw new Error('Multiplier must be positive non-zero'); + if (multiplier === 1) return this; + const newCanonicalDuration = this.getCanonicalLength() * multiplier; + return Duration.fromCanonicalLength(newCanonicalDuration); + } + + public valueOf() { + return this.spans; + } + + public equals(other: Duration | undefined): boolean { + return other instanceof Duration && this.toString() === other.toString(); + } + + public isSimple(): boolean { + const { singleSpan } = this; + if (!singleSpan) return false; + return this.spans[singleSpan] === 1; + } + + public isFloorable(): boolean { + const { singleSpan } = this; + if (!singleSpan) return false; + const span = Number(this.spans[singleSpan]); + if (span === 1) return true; + const { siblings } = shifters[singleSpan]; + if (!siblings) return false; + return siblings % span === 0; + } + + /** + * Floors the date according to this duration. + * @param date The date to floor + * @param timezone The timezone within which to floor + */ + public floor(date: Date, timezone: string): Date { + const { singleSpan } = this; + if (!singleSpan) throw new Error('Can not floor on a complex duration'); + const span = this.spans[singleSpan]!; + const mover = shifters[singleSpan]; + let dt = mover.floor(date, timezone); + if (span !== 1) { + if (!mover.siblings) { + throw new Error(`Can not floor on a ${singleSpan} duration that is not 1`); + } + if (mover.siblings % span !== 0) { + throw new Error( + `Can not floor on a ${singleSpan} duration that does not divide into ${mover.siblings}`, + ); + } + dt = mover.round(dt, span, timezone); + } + return dt; + } + + /** + * Moves the given date by 'step' times of the duration + * Negative step value will move back in time. + * @param date The date to move + * @param timezone The timezone within which to make the move + * @param step The number of times to step by the duration + */ + public shift(date: Date, timezone: string, step = 1): Date { + const spans = this.spans; + for (const span of SPANS_WITH_WEEK) { + const value = spans[span]; + if (value) date = shifters[span].shift(date, timezone, step * value); + } + return date; + } + + public ceil(date: Date, timezone: string): Date { + const floored = this.floor(date, timezone); + if (floored.valueOf() === date.valueOf()) return date; // Just like ceil(3) is 3 and not 4 + return this.shift(floored, timezone, 1); + } + + public round(date: Date, timezone: string): Date { + const floorDate = this.floor(date, timezone); + const ceilDate = this.ceil(date, timezone); + const distanceToFloor = Math.abs(date.valueOf() - floorDate.valueOf()); + const distanceToCeil = Math.abs(date.valueOf() - ceilDate.valueOf()); + return distanceToFloor < distanceToCeil ? floorDate : ceilDate; + } + + /** + * Materializes all the values of this duration form start to end + * @param start The date to start on + * @param end The date to start on + * @param timezone The timezone within which to materialize + * @param step The number of times to step by the duration + */ + public materialize(start: Date, end: Date, timezone: string, step = 1): Date[] { + const values: Date[] = []; + let iter = this.floor(start, timezone); + while (iter <= end) { + values.push(iter); + iter = this.shift(iter, timezone, step); + } + return values; + } + + /** + * Checks to see if date is aligned to this duration within the timezone (floors to itself) + * @param date The date to check + * @param timezone The timezone within which to make the check + */ + public isAligned(date: Date, timezone: string): boolean { + return this.floor(date, timezone).valueOf() === date.valueOf(); + } + + /** + * Check to see if this duration can be divided by the given duration + * @param smaller The smaller duration to divide by + */ + public dividesBy(smaller: Duration): boolean { + const myCanonicalLength = this.getCanonicalLength(); + const smallerCanonicalLength = smaller.getCanonicalLength(); + return ( + myCanonicalLength % smallerCanonicalLength === 0 && + this.isFloorable() && + smaller.isFloorable() + ); + } + + public getCanonicalLength(): number { + const spans = this.spans; + let length = 0; + for (const span of SPANS_WITH_WEEK) { + const value = spans[span]; + if (value) length += value * shifters[span].canonicalLength; + } + return length; + } + + public getDescription(capitalize?: boolean): string { + const spans = this.spans; + const description: string[] = []; + for (const span of SPANS_WITH_WEEK) { + const value = spans[span]; + const spanTitle = capitalize ? capitalizeFirst(span) : span; + if (value) { + if (value === 1 && this.singleSpan) { + description.push(spanTitle); + } else { + description.push(pluralIfNeeded(value, spanTitle)); + } + } + } + return description.join(', '); + } + + public getSingleSpan(): string | undefined { + return this.singleSpan; + } + + public getSingleSpanValue(): number | undefined { + if (!this.singleSpan) return; + return this.spans[this.singleSpan]; + } + + public limitToDays(): Duration { + return Duration.fromCanonicalLengthUpToDays(this.getCanonicalLength()); + } +} diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx index edea5ad0a52..096f0dfe063 100644 --- a/web-console/src/utils/index.tsx +++ b/web-console/src/utils/index.tsx @@ -19,10 +19,12 @@ export * from './base64-url'; export * from './column-metadata'; export * from './date'; +export * from './date-floor-shift-ceil/date-floor-shift-ceil'; export * from './download'; export * from './download-query-detail-archive'; export * from './druid-lookup'; export * from './druid-query'; +export * from './duration/duration'; export * from './formatter'; export * from './general'; export * from './local-storage-backed-visibility'; diff --git a/web-console/src/views/datasources-view/datasources-view.tsx b/web-console/src/views/datasources-view/datasources-view.tsx index a2bd3692346..4477540af66 100644 --- a/web-console/src/views/datasources-view/datasources-view.tsx +++ b/web-console/src/views/datasources-view/datasources-view.tsx @@ -53,7 +53,13 @@ import type { QueryWithContext, Rule, } from '../../druid-models'; -import { formatCompactionInfo, RuleUtil, zeroCompactionStatus } from '../../druid-models'; +import { + END_OF_TIME_DATE, + formatCompactionInfo, + RuleUtil, + START_OF_TIME_DATE, + zeroCompactionStatus, +} from '../../druid-models'; import type { Capabilities, CapabilitiesMode } from '../../helpers'; import { STANDARD_TABLE_PAGE_SIZE, STANDARD_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table'; import { Api, AppToaster } from '../../singletons'; @@ -361,7 +367,7 @@ export class DatasourcesView extends React.PureComponent< `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE '%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z') AS day_aligned_segments`, `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE '%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z') AS month_aligned_segments`, `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE '%-01-01T00:00:00.000Z' AND "end" LIKE '%-01-01T00:00:00.000Z') AS year_aligned_segments`, - `COUNT(*) FILTER (WHERE is_active = 1 AND "start" = '-146136543-09-08T08:23:32.096Z' AND "end" = '146140482-04-24T15:36:27.903Z') AS all_granularity_segments`, + `COUNT(*) FILTER (WHERE is_active = 1 AND "start" = '${START_OF_TIME_DATE}' AND "end" = '${END_OF_TIME_DATE}') AS all_granularity_segments`, ], visibleColumns.shown('Total rows') && `SUM("num_rows") FILTER (WHERE is_active = 1) AS total_rows`, diff --git a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx b/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx index e5f2fb47f9a..f5a25d2ea0a 100644 --- a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx +++ b/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx @@ -26,6 +26,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { Loader } from '../../../components'; import { useQueryManager } from '../../../hooks'; import { + Duration, formatInteger, formatNumber, prettyFormatIsoDateTick, @@ -35,7 +36,7 @@ import { Issue } from '../components'; import { highlightStore } from '../highlight-store/highlight-store'; import type { ExpressionMeta } from '../models'; import { ModuleRepository } from '../module-repository/module-repository'; -import { DATE_FORMAT, getAutoGranularity, snapToGranularity } from '../utils'; +import { DATE_FORMAT, getAutoGranularity } from '../utils'; import './record-table-module.scss'; @@ -223,12 +224,9 @@ ModuleRepository.registerModule<MultiAxisChartParameterValues>({ // this is only used for the label and the data saved in the highlight // the positioning is done with the true coordinates until the user // releases the mouse button (in the `brushend` event) - const { start, end } = snapToGranularity( - params.areas[0].coordRange[0], - params.areas[0].coordRange[1], - timeGranularity, - undefined, // context.timezone, - ); + const duration = new Duration(timeGranularity); + const start = duration.round(params.areas[0].coordRange[0], 'Etc/UTC'); + const end = duration.round(params.areas[0].coordRange[1], 'Etc/UTC'); const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[0]); const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[1]); diff --git a/web-console/src/views/explore-view/modules/time-chart-module.tsx b/web-console/src/views/explore-view/modules/time-chart-module.tsx index 1c19963b1ed..661e3ec4575 100644 --- a/web-console/src/views/explore-view/modules/time-chart-module.tsx +++ b/web-console/src/views/explore-view/modules/time-chart-module.tsx @@ -25,6 +25,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { Loader } from '../../../components'; import { useQueryManager } from '../../../hooks'; import { + Duration, formatInteger, formatNumber, prettyFormatIsoDateTick, @@ -34,7 +35,7 @@ import { Issue } from '../components'; import { highlightStore } from '../highlight-store/highlight-store'; import type { ExpressionMeta } from '../models'; import { ModuleRepository } from '../module-repository/module-repository'; -import { DATE_FORMAT, getAutoGranularity, snapToGranularity } from '../utils'; +import { DATE_FORMAT, getAutoGranularity } from '../utils'; import './record-table-module.scss'; @@ -295,13 +296,13 @@ ModuleRepository.registerModule<TimeChartParameterValues>({ // this is only used for the label and the data saved in the highlight // the positioning is done with the true coordinates until the user // releases the mouse button (in the `brushend` event) - const { start, end } = snappyHighlight - ? snapToGranularity( - params.areas[0].coordRange[0], - params.areas[0].coordRange[1], - timeGranularity, - ) - : { start: params.areas[0].coordRange[0], end: params.areas[0].coordRange[1] }; + let start = params.areas[0].coordRange[0]; + let end = params.areas[0].coordRange[1]; + if (snappyHighlight) { + const duration = new Duration(timeGranularity); + start = duration.round(start, 'Etc/UTC'); + end = duration.round(end, 'Etc/UTC'); + } const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[0]); const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[1]); diff --git a/web-console/src/views/explore-view/utils/duration.ts b/web-console/src/views/explore-view/utils/duration.ts deleted file mode 100644 index 621aa0a57a0..00000000000 --- a/web-console/src/views/explore-view/utils/duration.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { sum } from 'd3-array'; - -import { filterMap, pluralIfNeeded } from '../../../utils'; - -const SPANS = ['year', 'month', 'day', 'hour', 'minute', 'second']; - -export function formatDuration(duration: string, preferCompact = false): string { - // Regular expressions to match ISO 8601 duration parts - const regex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; - const matches = regex.exec(duration); - if (!matches) return duration; - - // Extract the relevant parts - const counts = SPANS.map((_, i) => parseInt(matches[i + 1] || '0', 10)); - - // Construct the human-readable format based on the parsed values - let parts: string[]; - if (preferCompact && sum(counts) === 1) { - parts = filterMap(SPANS, (span, i) => (counts[i] ? span : undefined)); - } else { - parts = filterMap(SPANS, (span, i) => - counts[i] ? pluralIfNeeded(counts[i], span) : undefined, - ); - } - - // Join the parts with commas, and return the result - return parts.length > 0 ? parts.join(', ') : '0 seconds'; -} diff --git a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts index 6b2245dbd38..57a654fa10e 100644 --- a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts +++ b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts @@ -18,8 +18,9 @@ import type { Column, FilterPattern } from '@druid-toolkit/query'; +import { Duration } from '../../../utils'; + import { DATE_FORMAT } from './date-format'; -import { formatDuration } from './duration'; const TIME_RELATIVE_TYPES: Record<string, string> = { 'maxDataTime/': 'latest', @@ -82,10 +83,9 @@ export function formatPatternWithoutNegation(pattern: FilterPattern): string { case 'timeRelative': { const type = TIME_RELATIVE_TYPES[`${pattern.anchor}/${pattern.alignType || ''}`]; - return `${pattern.column} in ${type ? `${type} ` : ''}${formatDuration( + return `${pattern.column} in ${type ? `${type} ` : ''}${new Duration( pattern.rangeDuration, - true, - )}`; + ).getDescription()}`; } case 'numberRange': diff --git a/web-console/src/views/explore-view/utils/get-auto-granularity.ts b/web-console/src/views/explore-view/utils/get-auto-granularity.ts index f8928f3b86b..21cc2743820 100644 --- a/web-console/src/views/explore-view/utils/get-auto-granularity.ts +++ b/web-console/src/views/explore-view/utils/get-auto-granularity.ts @@ -18,7 +18,8 @@ import type { SqlExpression } from '@druid-toolkit/query'; import { fitFilterPattern, SqlMulti } from '@druid-toolkit/query'; -import { day, Duration, hour } from 'chronoshift'; + +import { day, Duration, hour } from '../../../utils'; function getCanonicalDuration( expression: SqlExpression, @@ -32,7 +33,7 @@ function getCanonicalDuration( return pattern.end.valueOf() - pattern.start.valueOf(); case 'timeRelative': - return Duration.fromJS(pattern.rangeDuration).getCanonicalLength(); + return new Duration(pattern.rangeDuration).getCanonicalLength(); case 'custom': if (pattern.expression instanceof SqlMulti) { @@ -76,7 +77,5 @@ export function getAutoGranularity(where: SqlExpression, timeColumnName: string) return 'PT1M'; } - console.debug('Unable to determine granularity from where clause', where.toString()); - return DEFAULT_GRANULARITY; } diff --git a/web-console/src/views/explore-view/utils/index.ts b/web-console/src/views/explore-view/utils/index.ts index 8469c83b927..57c72e2c0e7 100644 --- a/web-console/src/views/explore-view/utils/index.ts +++ b/web-console/src/views/explore-view/utils/index.ts @@ -17,7 +17,6 @@ */ export * from './date-format'; -export * from './duration'; export * from './filter-pattern-helpers'; export * from './general'; export * from './get-auto-granularity'; @@ -25,6 +24,5 @@ export * from './known-aggregations'; export * from './max-time-for-table'; export * from './misc'; export * from './query-log'; -export * from './snap-to-granularity'; export * from './table-query'; export * from './time-manipulation'; diff --git a/web-console/src/views/explore-view/utils/snap-to-granularity.ts b/web-console/src/views/explore-view/utils/snap-to-granularity.ts deleted file mode 100644 index 15b0cce0351..00000000000 --- a/web-console/src/views/explore-view/utils/snap-to-granularity.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Duration, Timezone } from 'chronoshift'; - -/** - * Will try to snap start and end to the closest available dates, given the granularity. - * - * @param start the start date - * @param end the end date - * @param granularity the granularity - * @param timezone the timezone - * @returns an object with the start and end dates snapped to the given granularity - */ -export function snapToGranularity( - start: Date, - end: Date, - granularity: string, - timezone?: string, -): { start: Date; end: Date } { - const tz = Timezone.fromJS(timezone || 'Etc/UTC'); - const duration = Duration.fromJS(granularity); - - // get closest to start - const flooredStart = duration.floor(start, tz); - const ceiledStart = duration.shift(flooredStart, tz, 1); - const distanceToFlooredStart = Math.abs(start.valueOf() - flooredStart.valueOf()); - const distanceToCeiledStart = Math.abs(start.valueOf() - ceiledStart.valueOf()); - const closestStart = distanceToFlooredStart < distanceToCeiledStart ? flooredStart : ceiledStart; - - // get closest to end - const flooredEnd = duration.floor(end, tz); - const ceiledEnd = duration.shift(flooredEnd, tz, 1); - const distanceToFlooredEnd = Math.abs(end.valueOf() - flooredEnd.valueOf()); - const distanceToCeiledEnd = Math.abs(end.valueOf() - ceiledEnd.valueOf()); - const closestEnd = distanceToFlooredEnd < distanceToCeiledEnd ? flooredEnd : ceiledEnd; - - return { - start: closestStart, - end: closestEnd, - }; -} diff --git a/web-console/src/views/explore-view/utils/table-query.ts b/web-console/src/views/explore-view/utils/table-query.ts index bc5b6484c9a..633f02ff15b 100644 --- a/web-console/src/views/explore-view/utils/table-query.ts +++ b/web-console/src/views/explore-view/utils/table-query.ts @@ -31,11 +31,10 @@ import { } from '@druid-toolkit/query'; import type { ColumnHint } from '../../../utils'; -import { forceSignInNumberFormatter, formatNumber, formatPercent } from '../../../utils'; +import { Duration, forceSignInNumberFormatter, formatNumber, formatPercent } from '../../../utils'; import type { ExpressionMeta } from '../models'; import { Measure } from '../models'; -import { formatDuration } from './duration'; import { addTableScope } from './general'; import { KNOWN_AGGREGATIONS } from './known-aggregations'; import type { Compare } from './time-manipulation'; @@ -103,7 +102,7 @@ function makeBaseColumnHints( ).getUnderlyingExpression(), }; if (isTimestamp(splitColumn)) { - hint.displayName = `${splitColumn.name} (by ${formatDuration(timeBucket, true)})`; + hint.displayName = `${splitColumn.name} (by ${new Duration(timeBucket).getDescription()})`; } columnHints.set(splitColumn.name, hint); } @@ -291,7 +290,7 @@ function makeCompareAggregatorsAndAddHints( prevMeasure: SqlExpression, columnHints: Map<string, ColumnHint>, ): SqlExpression[] { - const group = `Previous ${formatDuration(compare, true)}`; + const group = `Previous ${new Duration(compare).getDescription()}`; const diff = mainMeasure.subtract(prevMeasure); const ret: SqlExpression[] = []; diff --git a/web-console/src/views/segments-view/segments-view.tsx b/web-console/src/views/segments-view/segments-view.tsx index 18a44e66180..ded3ac5474c 100644 --- a/web-console/src/views/segments-view/segments-view.tsx +++ b/web-console/src/views/segments-view/segments-view.tsx @@ -44,6 +44,7 @@ import { AsyncActionDialog } from '../../dialogs'; import { SegmentTableActionDialog } from '../../dialogs/segments-table-action-dialog/segment-table-action-dialog'; import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog'; import type { QueryWithContext } from '../../druid-models'; +import { computeSegmentTimeSpan } from '../../druid-models'; import type { Capabilities, CapabilitiesMode } from '../../helpers'; import { booleanCustomTableFilter, @@ -84,7 +85,7 @@ const TABLE_COLUMNS_BY_MODE: Record<CapabilitiesMode, TableColumnSelectorColumn[ 'Start', 'End', 'Version', - { text: 'Time span', label: '𝑓(sys.segments)' }, + 'Time span', 'Shard type', 'Shard spec', 'Partition', @@ -106,7 +107,7 @@ const TABLE_COLUMNS_BY_MODE: Record<CapabilitiesMode, TableColumnSelectorColumn[ 'Start', 'End', 'Version', - { text: 'Time span', label: '𝑓(sys.segments)' }, + 'Time span', 'Shard type', 'Shard spec', 'Partition', @@ -177,7 +178,6 @@ interface SegmentQueryResultRow { interval: string; segment_id: string; version: string; - time_span: string; shard_spec: string; partition_num: number; size: number; @@ -225,16 +225,6 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment `"start"`, `"end"`, `"version"`, - visibleColumns.shown('Time span') && - `CASE - WHEN "start" = '-146136543-09-08T08:23:32.096Z' AND "end" = '146140482-04-24T15:36:27.903Z' THEN 'All' - WHEN "start" LIKE '%-01-01T00:00:00.000Z' AND "end" LIKE '%-01-01T00:00:00.000Z' THEN 'Year' - WHEN "start" LIKE '%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z' THEN 'Month' - WHEN "start" LIKE '%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z' THEN 'Day' - WHEN "start" LIKE '%:00:00.000Z' AND "end" LIKE '%:00:00.000Z' THEN 'Hour' - WHEN "start" LIKE '%:00.000Z' AND "end" LIKE '%:00.000Z' THEN 'Minute' - ELSE 'Sub minute' -END AS "time_span"`, visibleColumns.shown('Shard type', 'Shard spec') && `"shard_spec"`, visibleColumns.shown('Partition') && `"partition_num"`, visibleColumns.shown('Size') && `"size"`, @@ -253,30 +243,6 @@ END AS "time_span"`, return `WITH s AS (SELECT\n${columns.join(',\n')}\nFROM sys.segments)`; } - static computeTimeSpan(start: string, end: string): string { - if (start.endsWith('-01-01T00:00:00.000Z') && end.endsWith('-01-01T00:00:00.000Z')) { - return 'Year'; - } - - if (start.endsWith('-01T00:00:00.000Z') && end.endsWith('-01T00:00:00.000Z')) { - return 'Month'; - } - - if (start.endsWith('T00:00:00.000Z') && end.endsWith('T00:00:00.000Z')) { - return 'Day'; - } - - if (start.endsWith(':00:00.000Z') && end.endsWith(':00:00.000Z')) { - return 'Hour'; - } - - if (start.endsWith(':00.000Z') && end.endsWith(':00.000Z')) { - return 'Minute'; - } - - return 'Sub minute'; - } - private readonly segmentsQueryManager: QueryManager<SegmentsQuery, SegmentQueryResultRow[]>; constructor(props: SegmentsViewProps) { @@ -287,7 +253,7 @@ END AS "time_span"`, segmentsState: QueryState.INIT, visibleColumns: new LocalStorageBackedVisibility( LocalStorageKeys.SEGMENT_TABLE_COLUMN_SELECTION, - ['Time span', 'Is published', 'Is overshadowed'], + ['Is published', 'Is overshadowed'], ), groupByInterval: false, showSegmentTimeline: false, @@ -412,7 +378,6 @@ END AS "time_span"`, end, interval: segment.interval, version: segment.version, - time_span: SegmentsView.computeTimeSpan(start, end), shard_spec: deepGet(segment, 'shardSpec'), partition_num: deepGet(segment, 'shardSpec.partitionNum') || 0, size: segment.size, @@ -615,7 +580,7 @@ END AS "time_span"`, show: visibleColumns.shown('Start'), accessor: 'start', headerClassName: 'enable-comparisons', - width: 160, + width: 180, sortable: hasSql, defaultSortDesc: true, filterable: allowGeneralFilter, @@ -626,7 +591,7 @@ END AS "time_span"`, show: visibleColumns.shown('End'), accessor: 'end', headerClassName: 'enable-comparisons', - width: 160, + width: 180, sortable: hasSql, defaultSortDesc: true, filterable: allowGeneralFilter, @@ -636,20 +601,21 @@ END AS "time_span"`, Header: 'Version', show: visibleColumns.shown('Version'), accessor: 'version', - width: 160, + width: 180, sortable: hasSql, defaultSortDesc: true, filterable: allowGeneralFilter, - Cell: this.renderFilterableCell('version'), + Cell: this.renderFilterableCell('version', true), }, { Header: 'Time span', show: visibleColumns.shown('Time span'), - accessor: 'time_span', + id: 'time_span', + className: 'padded', + accessor: ({ start, end }) => computeSegmentTimeSpan(start, end), width: 100, - sortable: hasSql, - filterable: allowGeneralFilter, - Cell: this.renderFilterableCell('time_span'), + sortable: false, + filterable: false, }, { Header: 'Shard type', --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
