diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 57c7f60ba58c..a9175ad23f8b 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -346,7 +346,7 @@ jobs: - name: Set OpenSearch URL run: | - echo "OPENSEARCH_URL=https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/${{ env.VERSION }}/latest/linux/x64/tar/dist/opensearch/opensearch-${{ env.VERSION }}-linux-x64.tar.gz" >> $GITHUB_ENV + echo "OPENSEARCH_URL=https://artifacts.opensearch.org/snapshots/core/opensearch/${{ env.VERSION }}-SNAPSHOT/opensearch-min-${{ env.VERSION }}-SNAPSHOT-linux-x64-latest.tar.gz" >> $GITHUB_ENV - name: Verify if OpenSearch is available for version id: verify-opensearch-exists diff --git a/.lycheeexclude b/.lycheeexclude index 5bb9c969dad1..67ed88344a25 100644 --- a/.lycheeexclude +++ b/.lycheeexclude @@ -124,3 +124,4 @@ http://helpmenow.com/problem2 https://sass-lang.com/* http://api.jquery.com/* http://brandonaaron.net +https://www.circl.lu/doc/misp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e780bfe0aeff..237a2270dc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,9 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [TSVB, Dashboards] Fix inconsistent dark mode code editor themes ([#4609](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4609)) - [Legacy Maps] Fix dark mode style overrides ([#4658](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4658)) - [BUG] Fix management overview page duplicate rendering ([#4636](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4636)) -- Fixes broken app when management is turned off ([#4891](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4891)) - Bump `agentkeepalive` to v4.5.0 to solve a problem preventing the use `https://ip` in `opensearch.hosts` ([#4949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4949)) +- Fix broken app when management is turned off ([#4891](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4891)) +- Correct the generated path for downloading plugins by their names on Windows ([#4953](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4953)) ### ๐Ÿšž Infrastructure @@ -146,6 +147,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Enable plugins to augment visualizations with additional data and context ([#4361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4361)) - Dashboard De-Angularization ([#4502](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4502)) - New management overview page and rename stack management to dashboard management ([#4287](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4287)) +- [Console] Add support for JSON with long numerals ([#4562](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4562)) - [Vis Augmenter] Update base vis height in view events flyout ([#4535](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4535)) - [Dashboard De-Angular] Add more unit tests for utils folder ([#4641](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4641)) - [Dashboard De-Angular] Add unit tests for dashboard_listing and dashboard_top_nav ([#4640](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4640)) @@ -325,6 +327,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [VisBuilder] Add metric to metric, bucket to bucket aggregation persistence ([#3495](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3495)) - [VisBuilder] Add UI actions handler ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) - [VisBuilder] Add persistence to visualizations inner state ([#3751](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3751)) +- [Console] Add support for exporting and restoring commands in Dev Tools ([#3810](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3810)) ### ๐Ÿ› Bug Fixes diff --git a/package.json b/package.json index e298426123c3..e74416f31d72 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "@hapi/podium": "^4.1.3", "@hapi/vision": "^6.1.0", "@hapi/wreck": "^17.1.0", - "@opensearch-project/opensearch": "^2.2.0", + "@opensearch-project/opensearch": "^2.3.1", "@osd/ace": "1.0.0", "@osd/analytics": "1.0.0", "@osd/apm-config-loader": "1.0.0", diff --git a/packages/osd-opensearch-archiver/package.json b/packages/osd-opensearch-archiver/package.json index 1c036dc10c50..4246817d4daf 100644 --- a/packages/osd-opensearch-archiver/package.json +++ b/packages/osd-opensearch-archiver/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "@osd/dev-utils": "1.0.0", - "@opensearch-project/opensearch": "^2.2.0" + "@osd/std": "1.0.0", + "@opensearch-project/opensearch": "^2.3.1" }, "devDependencies": {} } diff --git a/packages/osd-opensearch/package.json b/packages/osd-opensearch/package.json index 44404a9ae5a3..1675b0ef134a 100644 --- a/packages/osd-opensearch/package.json +++ b/packages/osd-opensearch/package.json @@ -12,7 +12,7 @@ "osd:watch": "../../scripts/use_node scripts/build --watch" }, "dependencies": { - "@opensearch-project/opensearch": "^2.2.0", + "@opensearch-project/opensearch": "^2.3.1", "@osd/dev-utils": "1.0.0", "abort-controller": "^3.0.0", "chalk": "^4.1.0", diff --git a/packages/osd-std/README.md b/packages/osd-std/README.md index 3730735cb5a4..24f888979d25 100644 --- a/packages/osd-std/README.md +++ b/packages/osd-std/README.md @@ -1,3 +1,73 @@ # `@osd/std` โ€” OpenSearch Dashboards standard library -This package is a set of utilities that can be used both on server-side and client-side. \ No newline at end of file +This package is a set of utilities that can be used both on server-side and client-side. + +## API + +#### `assertNever` + +Can be used in switch statements to ensure we perform exhaustive checks. + +#### `deepFreeze` + +Apply `Object.freeze` to a value recursively and convert the return type to `Readonly` variant recursively. + +#### `get` + +Retrieve the value for the specified path of an object. + +#### `getFlattenedObject` + +Flatten a deeply nested object to a map of dot-separated paths, pointing to all of its primitive values and arrays. + +#### `stringify` and `parse` + +Drop-in replacement for `JSON.stringify` and `JSON.parse`, capable of handling long numerals and `BigInt` values. + +#### `mapToObject` + +Convert a map to an object. + +#### `mapValuesOfMap` + +Create a new `Map` populated with the results of calling a provided function on every element in the input `Map`. + +#### `groupIntoMap` + +Group elements of an `Array` into a `Map` based on a provided function. + +#### `merge` + +Deeply merge two objects, omitting undefined values, and not deeply merging arrays. + +#### `pick` + +Create a new `Object` of specified keys and their values from an input `Object`. + +#### `withTimeout` + +Apply a `timeout` duration to a `Promise` before throwing an `Error` with the provided message. + +#### `firstValueFrom` and `lastValueFrom` + +Get a `Promise` that resolves as soon as the first or last value arrives from an observable. + +#### `unset` + +Unset a (potentially nested) key from given object. + +#### `modifyUrl` + +Get an `Object` resulting from applying a provided function to the meaningful parts of a URL. + +#### `isRelativeUrl` + +Determine if a url is relative. + +#### `getUrlOrigin` + +Get the origin URL of a provided URL. + +#### `validateObject` + +Deeply validate that an `Object` does not contain any `__proto__` or `constructor.prototype` keys, or circular references. \ No newline at end of file diff --git a/packages/osd-std/src/__snapshots__/json.test.ts.snap b/packages/osd-std/src/__snapshots__/json.test.ts.snap new file mode 100644 index 000000000000..42854db1dd78 --- /dev/null +++ b/packages/osd-std/src/__snapshots__/json.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`json can apply a replacer and spaces values while stringifying BigInts 1`] = ` +"{ + \\"\\\\\\": 18014398509481982\\": \\"\\", + \\"positive\\": 54043195528445946, + \\"negative\\": -54043195528445946, + \\"array\\": [ + -54043195528445946, + 54043195528445946, + [ + \\"]]>\\" + ] + ], + \\"number\\": \\"5d9d89cc6b13\\" +}" +`; + +exports[`json can handle BigInt values while stringifying 1`] = `"{\\"\\\\\\": 18014398509481982\\":\\"[ -18014398509481982, 18014398509481982 ]\\",\\"positive\\":18014398509481982,\\"negative\\":-18014398509481982,\\"array\\":[-18014398509481982,18014398509481982],\\"number\\":102931203123987}"`; diff --git a/packages/osd-std/src/index.ts b/packages/osd-std/src/index.ts index 0b3c65d8cc04..170c819a2b0a 100644 --- a/packages/osd-std/src/index.ts +++ b/packages/osd-std/src/index.ts @@ -42,3 +42,4 @@ export { unset } from './unset'; export { getFlattenedObject } from './get_flattened_object'; export { validateObject } from './validate_object'; export * from './rxjs_7'; +export { parse, stringify } from './json'; diff --git a/packages/osd-std/src/json.test.ts b/packages/osd-std/src/json.test.ts new file mode 100644 index 000000000000..33abd71d91d2 --- /dev/null +++ b/packages/osd-std/src/json.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { stringify, parse } from './json'; + +describe('json', () => { + it('can parse', () => { + const input = { + a: [ + { A: 1 }, + { B: '2' }, + { C: [1, 2, 3, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'] }, + ], + b: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + c: { + i: {}, + ii: [], + iii: '', + iv: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + }; + const result = parse(JSON.stringify(input)); + expect(result).toEqual(input); + }); + + it('can stringify', () => { + const input = { + a: [ + { A: 1 }, + { B: '2' }, + { C: [1, 2, 3, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'] }, + ], + b: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + c: { + i: {}, + ii: [], + iii: '', + iv: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + }; + const result = stringify(input); + expect(result).toEqual(JSON.stringify(input)); + }); + + it('can apply a reviver while parsing', () => { + const input = { + A: 255, + B: { + i: [[]], + ii: 'Lorem ipsum', + iii: {}, + rand: Math.random(), + }, + }; + const text = JSON.stringify(input); + function reviver(this: any, key: string, val: any) { + if (Array.isArray(val) && toString.call(this) === '[object Object]') this._hasArrays = true; + else if (typeof val === 'string') val = ``; + else if (typeof val === 'number') val = val.toString(16); + else if (toString.call(this) === '[object Object]' && key === 'rand' && val === input.B.rand) + this._found = true; + return val; + } + + expect(parse(text, reviver)).toEqual(JSON.parse(text, reviver)); + }); + + it('can apply a replacer and spaces while stringifying', () => { + const input = { + A: 255, + B: { + i: [[]], + ii: 'Lorem ipsum', + iii: {}, + rand: Math.random(), + }, + }; + + function replacer(this: any, key: string, val: any) { + if (Array.isArray(val) && val.length === 0) val.push(''); + else if (typeof val === 'string') val = ``; + else if (typeof val === 'number') val = val.toString(16); + else if (toString.call(this) === '[object Object]' && key === 'rand' && val === input.B.rand) + val = 1; + return val; + } + + expect(stringify(input, replacer, 2)).toEqual(JSON.stringify(input, replacer, 2)); + }); + + it('can handle long numerals while parsing', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const text = + `{` + + // The space before and after the values, and the lack of spaces before comma are intentional + `"\\":${longPositive}": "[ ${longNegative.toString()}, ${longPositive.toString()} ]", ` + + `"positive": ${longPositive.toString()}, ` + + `"array": [ ${longNegative.toString()}, ${longPositive.toString()} ], ` + + `"negative": ${longNegative.toString()},` + + `"number": 102931203123987` + + `}`; + + const result = parse(text); + expect(result.positive).toBe(longPositive); + expect(result.negative).toBe(longNegative); + expect(result.array).toEqual([longNegative, longPositive]); + expect(result['":' + longPositive]).toBe( + `[ ${longNegative.toString()}, ${longPositive.toString()} ]` + ); + expect(result.number).toBe(102931203123987); + }); + + it('can handle BigInt values while stringifying', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const input = { + [`": ${longPositive}`]: `[ ${longNegative.toString()}, ${longPositive.toString()} ]`, + positive: longPositive, + negative: longNegative, + array: [longNegative, longPositive], + number: 102931203123987, + }; + + expect(stringify(input)).toMatchSnapshot(); + }); + + it('can apply a reviver on long numerals while parsing', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const text = + `{` + + // The space before and after the values, and the lack of spaces before comma are intentional + `"\\":${longPositive}": "[ ${longNegative.toString()}, ${longPositive.toString()} ]", ` + + `"positive": ${longPositive.toString()}, ` + + `"array": [ ${longNegative.toString()}, ${longPositive.toString()} ], ` + + `"negative": ${longNegative.toString()},` + + `"number": 102931203123987` + + `}`; + + const reviver = (key: string, val: any) => (typeof val === 'bigint' ? val * 3n : val); + + const result = parse(text, reviver); + expect(result.positive).toBe(longPositive * 3n); + expect(result.negative).toBe(longNegative * 3n); + expect(result.array).toEqual([longNegative * 3n, longPositive * 3n]); + expect(result['":' + longPositive]).toBe( + `[ ${longNegative.toString()}, ${longPositive.toString()} ]` + ); + expect(result.number).toBe(102931203123987); + }); + + it('can apply a replacer and spaces values while stringifying BigInts', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const input = { + [`": ${longPositive}`]: `[ ${longNegative.toString()}, ${longPositive.toString()} ]`, + positive: longPositive, + negative: longNegative, + array: [longNegative, longPositive, []], + number: 102931203123987, + }; + + function replacer(this: any, key: string, val: any) { + if (typeof val === 'bigint') val = val * 3n; + else if (Array.isArray(val) && val.length === 0) val.push(''); + else if (typeof val === 'string') val = ``; + else if (typeof val === 'number') val = val.toString(16); + return val; + } + + expect(stringify(input, replacer, 4)).toMatchSnapshot(); + }); +}); diff --git a/packages/osd-std/src/json.ts b/packages/osd-std/src/json.ts new file mode 100644 index 000000000000..7c619dcd1656 --- /dev/null +++ b/packages/osd-std/src/json.ts @@ -0,0 +1,321 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* In JavaScript, a `Number` is a 64-bit floating-point value which can store 16 digits. However, the + * serializer and deserializer will need to cater to numeric values generated by other languages which + * can have up to 19 digits. Native JSON parser and stringifier, incapable of handling the extra + * digits, corrupt the values, making them unusable. + * + * To work around this limitation, the deserializer converts long sequences of digits into strings and + * marks them before applying the parser. During the parsing, string values that begin with the mark + * are converted to `BigInt` values. + * Similarly, during stringification, the serializer converts `BigInt` values to marked strings and + * when done, it replaces them with plain numerals. + * + * `Number.MAX_SAFE_INTEGER`, 9,007,199,254,740,991, is the largest number that the native methods can + * parse and stringify, and any numeral greater than that would need to be translated using the + * workaround; all 17-digits or longer and only tail-end of the 16-digits need translation. It would + * be unfair to all the 16-digit numbers if the translation applied to `\d{16,}` only to cover the + * less than 10%. Hence, a RegExp is created to only match numerals too long to be a number. + * + * To make the explanation simpler, let's assume that MAX_SAFE_INTEGER is 8921 which has 4 digits. + * Starting from the right, we take each digit onwards, `[-9]`: + * 1) 7922 - 7929: 792[2-9]\d{0} + * 2) 7930 - 7999: 79[3-9]\d{1} + * 9) 9 + 1 = 10 which results in a rollover; no need to do anything. + * 8) 9000 - 9999: [9-9]\d{3} + * Finally we add anything 5 digits or longer: `\d{5,} + * + * Note: A better solution would use AST but considering its performance penalty, RegExp is the next + * best thing. + */ +const maxIntAsString = String(Number.MAX_SAFE_INTEGER); +const maxIntLength = maxIntAsString.length; +// Sub-patterns for each digit +const longNumeralMatcherTokens = [`\\d{${maxIntAsString.length + 1},}`]; +for (let i = 0; i < maxIntLength; i++) { + if (maxIntAsString[i] !== '9') { + longNumeralMatcherTokens.push( + maxIntAsString.substring(0, i) + + `[${parseInt(maxIntAsString[i], 10) + 1}-9]` + + `\\d{${maxIntLength - i - 1}}` + ); + } +} + +/* The matcher that looks for `": , ...}` and `[..., , ...]` + * + * The pattern starts by looking for `":` not immediately preceded by a `\`. That should be + * followed by any of the numeric sub-patterns. A comma, end of an array, end of an object, or + * the end of the input are the only acceptable elements after it. + * + * Note: This RegExp can result in false-positive hits on the likes of `{"key": "[ ]"}` and + * those are cleaned out during parsing. + */ +const longNumeralMatcher = new RegExp( + `((?:\\[|,|(? { + // coverage:ignore-line + if (!length || length < 0) return []; + const choices = []; + const arr = markerChars; + const arrLength = arr.length; + const temp = Array(length); + + (function fill(pos, start) { + if (pos === length) return choices.push(temp.join('')); + + for (let i = start; i < arrLength; i++) { + temp[pos] = arr[i]; + fill(pos + 1, i); + } + })(0, 0); + + return choices; +}; + +/* Experiments with different combinations of various lengths, until one is found to not be in + * the input string. + */ +const getMarker = (text: string): { marker: string; length: number } => { + let marker; + let length = 0; + do { + length++; + getMarkerChoices(length).some((markerChoice) => { + if (text.indexOf(markerChoice) === -1) { + marker = markerChoice; + return true; + } + }); + } while (!marker); + + return { + marker, + length, + }; +}; + +const parseStringWithLongNumerals = ( + text: string, + reviver?: ((this: any, key: string, value: any) => any) | null +): any => { + const { marker, length } = getMarker(text); + + let hadException; + let obj; + let markedJSON = text.replace(longNumeralMatcher, `$1"${marker}$2"$3`); + const markedValueMatcher = new RegExp(`^${marker}-?\\d+$`); + + /* Convert marked values to BigInt values. + * The `startsWith` is purely for performance, to avoid running `test` if not needed. + */ + const convertMarkedValues = (val: any) => + typeof val === 'string' && val.startsWith(marker) && markedValueMatcher.test(val) + ? BigInt(val.substring(length)) + : val; + + /* For better performance, instead of testing for existence of `reviver` on each value, two almost + * identical functions are used. + */ + const parseMarkedText = reviver + ? (markedText: string) => + JSON.parse(markedText, function (key, val) { + return reviver.call(this, key, convertMarkedValues(val)); + }) + : (markedText: string) => JSON.parse(markedText, (key, val) => convertMarkedValues(val)); + + /* RegExp cannot replace AST and the process of marking adds quotes. So, any false-positive hit + * will make the JSON string unparseable. + * + * To find those instances, we try to parse and watch for the location of any errors. If an error + * is caused by the marking, we remove that single marking and try again. + */ + do { + try { + hadException = false; + obj = parseMarkedText(markedJSON); + } catch (e) { + hadException = true; + /* There are two types of exception objects that can be raised: + * 1) a proper object with lineNumber and columnNumber which we can use + * 2) a textual message with the position that we need to parse + */ + let { lineNumber, columnNumber } = e; + if (!lineNumber || !columnNumber) { + const match = e?.message?.match?.(/^Unexpected token.*at position (\d+)$/); + if (match) { + lineNumber = 1; + // The position is zero-indexed; adding 1 to normalize it for the -2 that comes later + columnNumber = parseInt(match[1], 10) + 1; + } + } + + if (lineNumber < 1 || columnNumber < 2) { + /* The problem is not with this replacement. + * Note: This will never happen because the outer parse would have already thrown. + */ + // coverage:ignore-line + throw e; + } + + /* We need to skip e.lineNumber - 1 number of `\n` occurrences. + * Then, we need to go to e.columnNumber - 2 to look for `"\d+"`; we need to `-1` to + * account for the quote but an additional `-1` is needed because columnNumber starts from 1. + */ + const re = new RegExp( + `^((?:.*\\n){${lineNumber - 1}}[^\\n]{${columnNumber - 2}})"${marker}(-?\\d+)"` + ); + if (!re.test(markedJSON)) { + /* The exception is not caused by adding the marker. + * Note: This will never happen because the outer parse would have already thrown. + */ + // coverage:ignore-line + throw e; + } + + // We have found a bad replacement; let's remove it. + markedJSON = markedJSON.replace(re, '$1$2'); + } + } while (hadException); + + return obj; +}; + +const stringifyObjectWithBigInts = ( + obj: any, + candidate: string, + replacer?: ((this: any, key: string, value: any) => any) | null, + space?: string | number +): string => { + const { marker } = getMarker(candidate); + + /* The matcher that looks for "" + * Because we have made sure that `marker` was never present in the original object, we can + * carelessly assume every "" is due to our marking. + */ + const markedBigIntMatcher = new RegExp(`"${marker}(-?\\d+)"`, 'g'); + + /* Convert BigInt values to a string and mark them. + * Can't be bothered with Number values outside the safe range because they are already corrupted. + * + * For better performance, instead of testing for existence of `replacer` on each value, two almost + * identical functions are used. + */ + const addMarkerToBigInts = replacer + ? function (this: any, key: string, val: any) { + // replacer is called before marking because marking changes the type + const newVal = replacer.call(this, key, val); + return typeof newVal === 'bigint' ? `${marker}${newVal.toString()}` : newVal; + } + : (key: string, val: any) => (typeof val === 'bigint' ? `${marker}${val.toString()}` : val); + + return ( + JSON.stringify(obj, addMarkerToBigInts, space) + // Replace marked substrings with just the numerals + .replace(markedBigIntMatcher, '$1') + ); +}; + +export const stringify = ( + obj: any, + replacer?: ((this: any, key: string, value: any) => any) | null, + space?: string | number +): string => { + let text; + let numeralsAreNumbers = true; + /* For better performance, instead of testing for existence of `replacer` on each value, two almost + * identical functions are used. + * + * Note: Converting BigInt values to numbers, `Number()` is much faster that `parseInt()`. Since we + * check the `type`, it is safe to just use `Number()`. + */ + const checkForBigInts = replacer + ? function (this: any, key: string, val: any) { + if (typeof val === 'bigint') { + numeralsAreNumbers = false; + return replacer.call(this, key, Number(val)); + } + return replacer.call(this, key, val); + } + : (key: string, val: any) => { + if (typeof val === 'bigint') { + numeralsAreNumbers = false; + return Number(val); + } + return val; + }; + + /* While this is a check for possibly having BigInt values, if none were found, the results is + * sufficient to fulfill the purpose of the function. However, if BigInt values were found, we will + * use `stringifyObjectWithBigInts` to do this again. + * + * The goal was not to punish every object that doesn't have a BigInt with the more expensive + * `stringifyObjectWithBigInts`. Those with BigInt values are also not unduly burdened because we + * still need it in its string form to find a suitable marker. + */ + text = JSON.stringify(obj, checkForBigInts, space); + + if (!numeralsAreNumbers) { + text = stringifyObjectWithBigInts(obj, text, replacer, space); + } + + return text; +}; + +export const parse = ( + text: string, + reviver?: ((this: any, key: string, value: any) => any) | null +): any => { + let obj; + let numeralsAreNumbers = true; + const inspectValueForLargeNumerals = (val: any) => { + if ( + numeralsAreNumbers && + typeof val === 'number' && + (val < Number.MAX_SAFE_INTEGER || val > Number.MAX_SAFE_INTEGER) + ) { + numeralsAreNumbers = false; + } + + // This function didn't have to have a return value but having it makes the rest cleaner + return val; + }; + + /* For better performance, instead of testing for existence of `reviver` on each value, two almost + * identical functions are used. + */ + const checkForLargeNumerals = reviver + ? function (this: any, key: string, val: any) { + return inspectValueForLargeNumerals(reviver.call(this, key, val)); + } + : (key: string, val: any) => inspectValueForLargeNumerals(val); + + /* While this is a check for possibly having BigInt values, if none were found, the results is + * sufficient to fulfill the purpose of the function. However, if BigInt values were found, we will + * use `stringifyObjectWithBigInts` to do this again. + * + * The goal was not to punish every object that doesn't have a BigInt with the more expensive + * `stringifyObjectWithBigInts`. Those with BigInt values are also not unduly burdened because we + * still need it in its string form to find a suitable marker. + */ + obj = JSON.parse(text, checkForLargeNumerals); + + if (!numeralsAreNumbers) { + obj = parseStringWithLongNumerals(text, reviver); + } + + return obj; +}; diff --git a/packages/osd-stylelint-config/config/global_selectors.json b/packages/osd-stylelint-config/config/global_selectors.json index 99b2db2dfb4f..760717c8dab5 100644 --- a/packages/osd-stylelint-config/config/global_selectors.json +++ b/packages/osd-stylelint-config/config/global_selectors.json @@ -24,7 +24,8 @@ "src/plugins/vis_builder/public/application/components/side_nav.scss", "packages/osd-ui-framework/src/components/button/button_group/_button_group.scss", "src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss", - "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss" + "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss", + "src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.scss" ] } -} \ No newline at end of file +} diff --git a/release-notes/opensearch-dashboards.release-notes-2.10.0.md b/release-notes/opensearch-dashboards.release-notes-2.10.0.md new file mode 100644 index 000000000000..6cdfffdba513 --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-2.10.0.md @@ -0,0 +1,84 @@ +## Version 2.10.0 Release Notes + +### ๐Ÿ›ก Security + +- Bump word-wrap from 1.2.3 to 1.2.4 ([#4589](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4589)) +- Bump version of tinygradient from 0.4.3 to 1.1.5 ([#4742](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4742)) +- Bump lmdb from 2.8.0 to 2.8.5 ([#4804](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4804)) +- Alias and bump mocha ([#4874](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4874)) +- Remove examples and other unwanted artifacts from installed dependencies ([#4896](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4896)) + +### ๐Ÿ“ˆ Features/Enhancements + +- [Vis colors] Update legacy mapped colors in charts plugin to use ouiPaletteColorBlind() ([#4398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4398)) +- [Saved Objects Management] Add new or remove extra tags and styles ([#4069](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4069)) +- Chore (home): Update visual consistency dashboard TSVB colors ([#4501](http://github.com/opensearch-project/OpenSearch-Dashboards/pull/4501)) +- Feature (home): Update visual consistency sample dashboard with more vis ([#4581](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4581)) +- Add resource ID filtering in fetch augment-vis obj queries ([#4608](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4608)) +- Enable theme-switching via Advanced Settings to preview the Next theme ([#4475](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4475)) +- Feat (home): Add remaining vis type examples ([#4619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4619)) +- Feat (Discover): Update styles to be compatible with next theme ([#4644](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4644)) +- Update webpack environment targets ([#4649](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4649)) +- [Table Visualization] Replace div containers with OuiFlex components ([#4272](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4272)) +- Reduce the amount of comments in compiled CSS ([#4648](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4648)) +- Feat (home): Remove color customizations from sample dashboards ([#4741](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4741)) +- Remove visualization editor background ([#4719](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4719)) +- Add saved objects service status api ([#4696](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4696)) +- Allow plugin manifest config to define semver compatible OpenSearch plugin and verify if it is installed on the cluster ([#4612](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4612)) +- Eliminate duplicate dashboard breadcrumb text ID ([#4805](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4805)) +- [@osd/pm] Automate multi-target bootstrap and build ([#4650](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4650)) +- [Home] Add modal to introduce the `next` theme ([#4715](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4715)) +- [Home] Add new theme sample dashboard screenshots ([#4906](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4906)) +- Change color fn used to calculate icon colors for search typeahead suggestions ([#4884](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4884)) +- [Next Theme] Make next theme the default ([#4854](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4854)) +- [Vis Colors] Update color mapper to prioritize unique colors per vis ([#4890](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4890)) +- [Advanced Settings] Consolidate settings into new "Appearance" category and add category IDs ([#4845](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4845)) +- Adds Data explorer framework and implements Discover using it ([#4806](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4806)) +- Use themes' definitions to render the initial view. This impacts the loading screen font and colors ([#4936](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4936)) + +### ๐Ÿ› Bug Fixes + +- [VisLib] Replace legend color palette with OUI color palette ([#4365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4365)) +- Fix (styles): Make ace code editor themes consistent ([#4609](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4609)) +- [i18n] fix generation scripts ([#4252](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4252)) +- Fix (Legacy Maps): Add necessary specificity for dark mode style overrides ([#4658](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4658)) +- Fix --font-text CSS var usage and add more leaflet font overrides ([#4674](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4674)) +- Fix snapshots that didn't get updated between PRs ([#4863](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4863)) +- [BUG] Fix management overview page duplicate rendering ([#4636](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4636)) +- Fixes broken app when management is turned off ([#4891](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4891)) +- [CCI] Fix EUI/OUI type errors ([#3798](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3798)) +- Correct the generated path for downloading plugins by their names on Windows ([#4953](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4953)) + +### ๐Ÿ“ Documentation + +- Add missing 1.3.x patch release notes to 2.x branch ([#4771](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4771)) +- [Vis Augmenter] Add documentation to `vis_augmenter` plugin ([#4527](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4527)) + +### ๐Ÿ›  Maintenance + +- Version increment from 2.9 to 2.10 ([#4545](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4545)) +- Bump OpenSearch-Dashboards 2.10.0 to use nodejs 18.16.0 version ([#4948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4948)) +- Bump `oui` to `1.3.0` ([#4941](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4941)) + +### ๐Ÿช› Refactoring + +- [Markdown] Replace custom css styles and native html with OUI ([#4390](http://github.com/opensearch-project/OpenSearch-Dashboards/pull/4390)) +- Removed KUI usage in `maps_legacy` plugin ([#3998](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3998)) +- [Console] Converted all `/lib/autocomplete/**/*.js` files to typescript ([#4148](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4148)) +- [Console] Convert all non-autocomplete lib files to typescript ([#4150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4150)) +- Refactor/remove breadcrumb styling main ([#4621](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4621)) +- Bump `node-sass` to a version that uses a newer `libsass` ([#4651](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4651)) +- [Dashboards] restructure folder to be more cohesive with the project ([#4575](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4575)) +- Remove minimum constraint on opensearch hosts ([#4701](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4701)) +- [CCI] Remove unused tags in the navigation plugin ([#3964](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3964)) +- Refactor logo usage ([#4702](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4702)) + +### ๐Ÿ”ฉ Tests + +- [CI] Fix BWC related CI failures by swapping dist url with snapshot url ([#4828](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4828)) +- [Dashboard De-Angular] Add unit tests for `dashboard_listing` and `dashboard_top_nav` ([#4640](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4640)) +- [Tests] Add BWC tests for 2.9 and 2.10 versions ([#4762](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4762)) +- [Stylelint] Add `no_restricted_values` linter rule ([#4413](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4413)) +- Units test for utils folder ([#4641](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4641)) +- Test (linkchecker): Exclude checking dead link ([#4720](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4720)) +- Update baseline images for functional tests ([#4879](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4879)) diff --git a/scripts/bwc/opensearch_service.sh b/scripts/bwc/opensearch_service.sh index ecd95990de7e..09f3b5d50c8c 100755 --- a/scripts/bwc/opensearch_service.sh +++ b/scripts/bwc/opensearch_service.sh @@ -24,7 +24,12 @@ function setup_opensearch() { function run_opensearch() { echo "[ Attempting to start OpenSearch... ]" cd "$OPENSEARCH_DIR" - spawn_process_and_save_PID "./opensearch-tar-install.sh > ${LOGS_DIR}/opensearch.log 2>&1 &" + # Check if opensearch-tar-install.sh exists + if [ -f "./opensearch-tar-install.sh" ]; then + spawn_process_and_save_PID "./opensearch-tar-install.sh > ${LOGS_DIR}/opensearch.log 2>&1 &" + else + spawn_process_and_save_PID "./bin/opensearch > ${LOGS_DIR}/opensearch.log 2>&1 &" + fi } # Checks the running status of OpenSearch diff --git a/src/cli_plugin/install/settings.js b/src/cli_plugin/install/settings.js index 2b0c34bfcd37..cfd576f8ff5a 100644 --- a/src/cli_plugin/install/settings.js +++ b/src/cli_plugin/install/settings.js @@ -42,10 +42,11 @@ function generateUrls({ version, plugin }) { } function generatePluginUrl(version, plugin) { - const platform = process.platform === 'win32' ? 'windows' : process.platform; + const [platform, type] = + process.platform === 'win32' ? ['windows', 'zip'] : [process.platform, 'tar']; const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; - return `${LATEST_PLUGIN_BASE_URL}/${version}/latest/${platform}/${arch}/tar/builds/opensearch-dashboards/plugins/${plugin}-${version}.zip`; + return `${LATEST_PLUGIN_BASE_URL}/${version}/latest/${platform}/${arch}/${type}/builds/opensearch-dashboards/plugins/${plugin}-${version}.zip`; } export function parseMilliseconds(val) { diff --git a/src/cli_plugin/install/settings.test.js b/src/cli_plugin/install/settings.test.js index ac7cf94e6761..60313e3fe9b8 100644 --- a/src/cli_plugin/install/settings.test.js +++ b/src/cli_plugin/install/settings.test.js @@ -157,7 +157,7 @@ describe('parse function', function () { "timeout": 0, "urls": Array [ "plugin name", - "https://ci.opensearch.org/ci/dbc/distribution-build-opensearch-dashboards/1234/latest/windows/x64/tar/builds/opensearch-dashboards/plugins/plugin name-1234.zip", + "https://ci.opensearch.org/ci/dbc/distribution-build-opensearch-dashboards/1234/latest/windows/x64/zip/builds/opensearch-dashboards/plugins/plugin name-1234.zip", ], "version": 1234, "workingPath": /plugins/.plugin.installing, diff --git a/src/core/public/http/fetch.test.ts b/src/core/public/http/fetch.test.ts index efc8d4aa31bb..20f070dbba80 100644 --- a/src/core/public/http/fetch.test.ts +++ b/src/core/public/http/fetch.test.ts @@ -825,4 +825,39 @@ describe('Fetch', () => { expect(usedSpy).toHaveBeenCalledTimes(2); }); }); + + describe('long numerals', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + + it('should use alternate parser on JSON responses when asked to', async () => { + fetchMock.get('*', { + body: `{"long-max": ${longPositive}, "long-min": ${longNegative}}`, + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + + await expect(fetchInstance.fetch('/my/path', { withLongNumerals: true })).resolves.toEqual({ + 'long-max': longPositive, + 'long-min': longNegative, + }); + }); + + it('should use alternate parser on non-JSON responses when asked to', async () => { + fetchMock.get('*', { + body: `{"long-max": ${longPositive}, "long-min": ${longNegative}}`, + status: 200, + headers: { + 'Content-Type': 'text', + }, + }); + + await expect(fetchInstance.fetch('/my/path', { withLongNumerals: true })).resolves.toEqual({ + 'long-max': longPositive, + 'long-min': longNegative, + }); + }); + }); }); diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 694372c46d99..767d58643003 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -31,7 +31,7 @@ import { omitBy } from 'lodash'; import { format } from 'url'; import { BehaviorSubject } from 'rxjs'; -import { isRelativeUrl } from '@osd/std'; +import { isRelativeUrl, parse } from '@osd/std'; import { IBasePath, @@ -190,12 +190,12 @@ export class Fetch { if (NDJSON_CONTENT.test(contentType)) { body = await response.blob(); } else if (JSON_CONTENT.test(contentType)) { - body = await response.json(); + body = fetchOptions.withLongNumerals ? parse(await response.text()) : await response.json(); } else { const text = await response.text(); try { - body = JSON.parse(text); + body = fetchOptions.withLongNumerals ? parse(text) : JSON.parse(text); } catch (err) { body = text; } diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index ab046e6d2d5a..3b7dff71c811 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -257,6 +257,12 @@ export interface HttpFetchOptions extends HttpRequestInit { * response information. When `false`, the return type will just be the parsed response body. Defaults to `false`. */ asResponse?: boolean; + + /** + * When `true`, if the response has a JSON mime type, the {@link HttpResponse} will use an alternate JSON parser + * that converts long numerals to BigInts. Defaults to `false`. + */ + withLongNumerals?: boolean; } /** diff --git a/src/core/server/opensearch/client/cluster_client.test.ts b/src/core/server/opensearch/client/cluster_client.test.ts index 1510d2b148fe..81f55b987805 100644 --- a/src/core/server/opensearch/client/cluster_client.test.ts +++ b/src/core/server/opensearch/client/cluster_client.test.ts @@ -99,8 +99,16 @@ describe('ClusterClient', () => { const scopedClusterClient = clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ headers: expect.any(Object) }); + const expected = { headers: expect.any(Object) }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); expect(scopedClusterClient.asInternalUser).toBe(clusterClient.asInternalUser); expect(scopedClusterClient.asCurrentUser).toBe(scopedClient.child.mock.results[0].value); @@ -113,7 +121,7 @@ describe('ClusterClient', () => { const scopedClusterClient1 = clusterClient.asScoped(request); const scopedClusterClient2 = clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenCalledTimes(2 * 2); expect(scopedClusterClient1).not.toBe(scopedClusterClient2); expect(scopedClusterClient1.asInternalUser).toBe(scopedClusterClient2.asInternalUser); @@ -135,10 +143,18 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, foo: 'bar', 'x-opaque-id': expect.any(String) }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('creates a scoped facade with filtered auth headers', () => { @@ -155,10 +171,18 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, authorization: 'auth', 'x-opaque-id': expect.any(String) }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respects auth headers precedence', () => { @@ -179,10 +203,18 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, authorization: 'auth', 'x-opaque-id': expect.any(String) }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('includes the `customHeaders` from the config without filtering them', () => { @@ -200,15 +232,23 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, foo: 'bar', hello: 'dolly', 'x-opaque-id': expect.any(String), }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('adds the x-opaque-id header based on the request id', () => { @@ -225,13 +265,21 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, 'x-opaque-id': 'my-fake-id', }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respect the precedence of auth headers over config headers', () => { @@ -251,15 +299,23 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, foo: 'auth', hello: 'dolly', 'x-opaque-id': expect.any(String), }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respect the precedence of request headers over config headers', () => { @@ -279,15 +335,23 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, foo: 'request', hello: 'dolly', 'x-opaque-id': expect.any(String), }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respect the precedence of config headers over default headers', () => { @@ -304,13 +368,21 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { [headerKey]: 'foo', 'x-opaque-id': expect.any(String), }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respect the precedence of request headers over default headers', () => { @@ -327,13 +399,21 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { [headerKey]: 'foo', 'x-opaque-id': expect.any(String), }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('respect the precedence of x-opaque-id header over config headers', () => { @@ -355,13 +435,21 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, 'x-opaque-id': 'from request', }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('filter headers when called with a `FakeRequest`', () => { @@ -380,10 +468,18 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, authorization: 'auth' }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); it('does not add auth headers when called with a `FakeRequest`', () => { @@ -404,10 +500,18 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); - expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ + const expected = { headers: { ...DEFAULT_HEADERS, foo: 'bar' }, - }); + }; + expect(scopedClient.child).toHaveBeenCalledTimes(2); + expect(scopedClient.child).toHaveBeenNthCalledWith(1, expect.objectContaining(expected)); + expect(scopedClient.child).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ...expected, + enableLongNumeralSupport: true, + }) + ); }); }); diff --git a/src/core/server/opensearch/client/cluster_client.ts b/src/core/server/opensearch/client/cluster_client.ts index ac2348921658..8ea87bb910f7 100644 --- a/src/core/server/opensearch/client/cluster_client.ts +++ b/src/core/server/opensearch/client/cluster_client.ts @@ -90,10 +90,27 @@ export class ClusterClient implements ICustomClusterClient { asScoped(request: ScopeableRequest) { const scopedHeaders = this.getScopedHeaders(request); + const scopedClient = this.rootScopedClient.child({ headers: scopedHeaders, }); - return new ScopedClusterClient(this.asInternalUser, scopedClient); + + const asInternalUserWithLongNumeralsSupport = this.asInternalUser.child({ + // @ts-expect-error - Remove ignoring after https://github.com/opensearch-project/opensearch-js/pull/598 is included in a release + enableLongNumeralSupport: true, + }); + + const scopedClientWithLongNumeralsSupport = this.rootScopedClient.child({ + headers: scopedHeaders, + // @ts-expect-error - Remove ignoring after https://github.com/opensearch-project/opensearch-js/pull/598 is included in a release + enableLongNumeralSupport: true, + }); + return new ScopedClusterClient( + this.asInternalUser, + scopedClient, + asInternalUserWithLongNumeralsSupport, + scopedClientWithLongNumeralsSupport + ); } public async close() { diff --git a/src/core/server/opensearch/client/mocks.ts b/src/core/server/opensearch/client/mocks.ts index 7b055d5b03cf..40b731f0f3bf 100644 --- a/src/core/server/opensearch/client/mocks.ts +++ b/src/core/server/opensearch/client/mocks.ts @@ -112,12 +112,16 @@ const createClientMock = (): OpenSearchClientMock => export interface ScopedClusterClientMock { asInternalUser: OpenSearchClientMock; asCurrentUser: OpenSearchClientMock; + asInternalUserWithLongNumeralsSupport: OpenSearchClientMock; + asCurrentUserWithLongNumeralsSupport: OpenSearchClientMock; } const createScopedClusterClientMock = () => { const mock: ScopedClusterClientMock = { asInternalUser: createClientMock(), asCurrentUser: createClientMock(), + asInternalUserWithLongNumeralsSupport: createClientMock(), + asCurrentUserWithLongNumeralsSupport: createClientMock(), }; return mock; @@ -126,12 +130,16 @@ const createScopedClusterClientMock = () => { export interface ClusterClientMock { asInternalUser: OpenSearchClientMock; asScoped: jest.MockedFunction<() => ScopedClusterClientMock>; + asInternalUserWithLongNumeralsSupport: OpenSearchClientMock; + asCurrentUserWithLongNumeralsSupport: jest.MockedFunction<() => ScopedClusterClientMock>; } const createClusterClientMock = () => { const mock: ClusterClientMock = { asInternalUser: createClientMock(), asScoped: jest.fn(), + asInternalUserWithLongNumeralsSupport: createClientMock(), + asCurrentUserWithLongNumeralsSupport: jest.fn(), }; mock.asScoped.mockReturnValue(createScopedClusterClientMock()); @@ -145,6 +153,8 @@ const createCustomClusterClientMock = () => { const mock: CustomClusterClientMock = { asInternalUser: createClientMock(), asScoped: jest.fn(), + asInternalUserWithLongNumeralsSupport: createClientMock(), + asCurrentUserWithLongNumeralsSupport: jest.fn(), close: jest.fn(), }; diff --git a/src/core/server/opensearch/client/scoped_cluster_client.test.ts b/src/core/server/opensearch/client/scoped_cluster_client.test.ts index 396075d35316..5e3d222c3be2 100644 --- a/src/core/server/opensearch/client/scoped_cluster_client.test.ts +++ b/src/core/server/opensearch/client/scoped_cluster_client.test.ts @@ -36,7 +36,12 @@ describe('ScopedClusterClient', () => { const internalClient = opensearchClientMock.createOpenSearchClient(); const scopedClient = opensearchClientMock.createOpenSearchClient(); - const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); + const scopedClusterClient = new ScopedClusterClient( + internalClient, + scopedClient, + internalClient, + scopedClient + ); expect(scopedClusterClient.asInternalUser).toBe(internalClient); }); @@ -45,7 +50,12 @@ describe('ScopedClusterClient', () => { const internalClient = opensearchClientMock.createOpenSearchClient(); const scopedClient = opensearchClientMock.createOpenSearchClient(); - const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); + const scopedClusterClient = new ScopedClusterClient( + internalClient, + scopedClient, + internalClient, + scopedClient + ); expect(scopedClusterClient.asCurrentUser).toBe(scopedClient); }); diff --git a/src/core/server/opensearch/client/scoped_cluster_client.ts b/src/core/server/opensearch/client/scoped_cluster_client.ts index d4db3ce3606c..4453243bd3ba 100644 --- a/src/core/server/opensearch/client/scoped_cluster_client.ts +++ b/src/core/server/opensearch/client/scoped_cluster_client.ts @@ -49,12 +49,25 @@ export interface IScopedClusterClient { * on behalf of the user that initiated the request to the OpenSearch Dashboards server. */ readonly asCurrentUser: OpenSearchClient; + /** + * A {@link OpenSearchClient | client}, with support for long numerals, to be used to + * query the opensearch cluster on behalf of the internal OpenSearch Dashboards user. + */ + readonly asInternalUserWithLongNumeralsSupport: OpenSearchClient; + /** + * A {@link OpenSearchClient | client}, with support for long numerals, to be used to + * query the opensearch cluster on behalf of the user that initiated the request to + * the OpenSearch Dashboards server. + */ + readonly asCurrentUserWithLongNumeralsSupport: OpenSearchClient; } /** @internal **/ export class ScopedClusterClient implements IScopedClusterClient { constructor( public readonly asInternalUser: OpenSearchClient, - public readonly asCurrentUser: OpenSearchClient + public readonly asCurrentUser: OpenSearchClient, + public readonly asInternalUserWithLongNumeralsSupport: OpenSearchClient, + public readonly asCurrentUserWithLongNumeralsSupport: OpenSearchClient ) {} } diff --git a/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap new file mode 100644 index 000000000000..bac6be2733fd --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap @@ -0,0 +1,489 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportFlyout Component renders correctly 1`] = ` + + + +
+ + +
+ + +
+ + + + + +
+
+ + + + + +
+
+
+`; diff --git a/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap new file mode 100644 index 000000000000..343c6b351229 --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportModeControl Component should render correclty 1`] = ` + + + Import options + + , + } + } +> + + +`; diff --git a/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap new file mode 100644 index 000000000000..27228e29674e --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OverwriteModal Component should render correclty 1`] = ` + +

+ Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "Merge with existing queries" option instead +

+
+`; diff --git a/src/plugins/console/public/application/components/import_flyout.test.tsx b/src/plugins/console/public/application/components/import_flyout.test.tsx new file mode 100644 index 000000000000..5e7093fe306c --- /dev/null +++ b/src/plugins/console/public/application/components/import_flyout.test.tsx @@ -0,0 +1,268 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ImportFlyout } from './import_flyout'; +import { ContextValue, ServicesContextProvider } from '../contexts'; +import { serviceContextMock } from '../contexts/services_context.mock'; +import { wrapWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { ReactWrapper, mount } from 'enzyme'; + +const mockFile = new File(['{"text":"Sample JSON data"}'], 'sample.json', { + type: 'application/json', +}); +const mockFile1 = new File(['{"text":"Sample JSON data1"}'], 'sample.json', { + type: 'application/json', +}); + +const mockInvalidFile = new File(['Some random data'], 'sample.json', { + type: 'application/json', +}); + +const filePickerIdentifier = '[data-test-subj="queryFilePicker"]'; +const confirmBtnIdentifier = '[data-test-subj="importQueriesConfirmBtn"]'; +const cancelBtnIdentifier = '[data-test-subj="importQueriesCancelBtn"]'; +const confirmModalConfirmButton = '[data-test-subj="confirmModalConfirmButton"]'; +const confirmModalCancelButton = '[data-test-subj="confirmModalCancelButton"]'; +const importErrorTextIdentifier = '[data-test-subj="importSenseObjectsErrorText"]'; +const mergeOptionIdentifier = '[id="overwriteDisabled"]'; +const overwriteOptionIdentifier = '[id="overwriteEnabled"]'; +const callOutIdentifier = 'EuiCallOut'; + +const invalidFileError = 'The selected file is not valid. Please select a valid JSON file.'; + +const defaultQuery = { + id: '43461752-1fd5-472e-8487-b47ff7fccbc8', + createdAt: 1690958998493, + updatedAt: 1690958998493, + text: 'GET _search\n{\n "query": {\n "match_all": {}\n }\n}', +}; + +describe('ImportFlyout Component', () => { + let mockedAppContextValue: ContextValue; + const mockClose = jest.fn(); + const mockRefresh = jest.fn(); + const mockFindAll = jest.fn(); + const mockCreate = jest.fn(); + const mockUpdate = jest.fn(); + + let component: ReactWrapper, React.Component<{}, {}, any>>; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockedAppContextValue = serviceContextMock.create(); + + mockedAppContextValue.services.objectStorageClient.text = { + create: mockCreate, + update: mockUpdate, + findAll: mockFindAll, + }; + + mockFindAll.mockResolvedValue([defaultQuery]); + + await act(async () => { + component = mount(wrapWithIntl(), { + wrappingComponent: ServicesContextProvider, + wrappingComponentProps: { + value: mockedAppContextValue, + }, + }); + }); + await nextTick(); + component.update(); + }); + + it('renders correctly', () => { + expect(component).toMatchSnapshot(); + }); + + it('should enable confirm button when select file', async () => { + // Confirm button should be disable if no file selected + expect(component.find(confirmBtnIdentifier).first().props().disabled).toBe(true); + component.update(); + + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile1]); + await new Promise((r) => setTimeout(r, 1)); + }); + await nextTick(); + component.update(); + + // Confirm button should be enable after importing file + expect(component.find(confirmBtnIdentifier).first().props().disabled).toBe(false); + }); + + it('should handle import process with default import mode', async () => { + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + // Applied a timeout after FileReader.onload event to resolve side effect issue. + await new Promise((r) => setTimeout(r, 10)); + }); + + await nextTick(); + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + // default option "Merge with existing queries" should be checked by default + expect(component.find(mergeOptionIdentifier).first().props().checked).toBe(true); + expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(false); + + // should update existing query + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); + + it('should handle errors during import', async () => { + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockInvalidFile]); + }); + + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + expect(component.find(callOutIdentifier).exists()).toBe(true); + + expect(component.find(importErrorTextIdentifier).text()).toEqual(invalidFileError); + }); + + it('should handle internal errors', async () => { + const errorMessage = 'some internal error'; + mockFindAll.mockRejectedValue(new Error(errorMessage)); + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + await new Promise((r) => setTimeout(r, 1)); + }); + + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + expect(component.find(callOutIdentifier).exists()).toBe(true); + + expect(component.find(importErrorTextIdentifier).text()).toEqual( + `The file could not be processed due to error: "${errorMessage}"` + ); + }); + + it('should cancel button work normally', async () => { + act(() => { + component.find(cancelBtnIdentifier).first().simulate('click'); + }); + + expect(mockClose).toBeCalledTimes(1); + }); + + describe('OverwriteModal', () => { + beforeEach(async () => { + jest.clearAllMocks(); + // Select a file + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + await new Promise((r) => setTimeout(r, 1)); + }); + component.update(); + + // change import mode to overwrite + await act(async () => { + await nextTick(); + component.find(overwriteOptionIdentifier).last().simulate('change'); + }); + + component.update(); + + // import selected file + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + }); + it('should handle overwrite confirmation', async () => { + // Check confirm overwrite modal exist before confirmation + expect(component.find('OverwriteModal').exists()).toBe(true); + + // confirm overwrite + await act(async () => { + await nextTick(); + component.find(confirmModalConfirmButton).first().simulate('click'); + }); + + component.update(); + + // should update existing query + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + + // confirm overwrite modal should close after confirmation. + expect(component.find('OverwriteModal').exists()).toBe(false); + }); + + it('should handle overwrite skip', async () => { + // Check confirm overwrite modal exist before skip + expect(component.find('OverwriteModal').exists()).toBe(true); + + // confirm overwrite + act(() => { + component.find(confirmModalCancelButton).first().simulate('click'); + }); + await nextTick(); + component.update(); + + // confirm overwrite modal should close after cancel. + expect(component.find('OverwriteModal').exists()).toBe(false); + }); + + it('should create storage text when storage client returns empty result with overwrite import mode', async () => { + mockFindAll.mockResolvedValue([]); + + // confirm overwrite + await act(async () => { + await nextTick(); + component.find(confirmModalConfirmButton).first().simulate('click'); + }); + + component.update(); + + expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(true); + + // should create new query + expect(mockCreate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/plugins/console/public/application/components/import_flyout.tsx b/src/plugins/console/public/application/components/import_flyout.tsx new file mode 100644 index 000000000000..d8c791411ade --- /dev/null +++ b/src/plugins/console/public/application/components/import_flyout.tsx @@ -0,0 +1,298 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiCallOut, + EuiSpacer, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLoadingSpinner, + EuiText, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { Fragment, useState } from 'react'; +import { ImportMode, ImportModeControl } from './import_mode_control'; +import { useServicesContext } from '../contexts'; +import { TextObject } from '../../../common/text_object'; +import { OverwriteModal } from './overwrite_modal'; + +const OVERWRITE_ALL_DEFAULT = false; + +export interface ImportFlyoutProps { + close: () => void; + refresh: () => void; +} + +const getErrorMessage = (e: any) => { + const errorMessage = + e.body?.error && e.body?.message ? `${e.body.error}: ${e.body.message}` : e.message; + return i18n.translate('console.ImportFlyout.importFileErrorMessage', { + defaultMessage: 'The file could not be processed due to error: "{error}"', + values: { + error: errorMessage, + }, + }); +}; + +export const ImportFlyout = ({ close, refresh }: ImportFlyoutProps) => { + const [error, setError] = useState(); + const [status, setStatus] = useState('idle'); + const [loadingMessage, setLoadingMessage] = useState(); + const [file, setFile] = useState(); + const [jsonData, setJsonData] = useState(); + const [showOverwriteModal, setShowOverwriteModal] = useState(false); + const [importMode, setImportMode] = useState({ + overwrite: OVERWRITE_ALL_DEFAULT, + }); + + const { + services: { + objectStorageClient, + uiSettings, + notifications: { toasts }, + }, + } = useServicesContext(); + + const dateFormat = uiSettings.get('dateFormat'); + + const setImportFile = (files: FileList | null) => { + const isFirstFileMissing = !files?.[0]; + if (isFirstFileMissing) { + setFile(undefined); + return; + } + const fileContent = files[0]; + const reader = new FileReader(); + + reader.onload = (event) => { + const fileData = event.target?.result; + if (typeof fileData === 'string') { + const parsedData = JSON.parse(fileData); + setJsonData(parsedData); + } + }; + + reader.readAsText(fileContent); + setFile(fileContent); + setStatus('idle'); + }; + + const renderError = () => { + if (status !== 'error') { + return null; + } + + return ( + + + } + color="danger" + > +

{error}

+
+ +
+ ); + }; + + const renderBody = () => { + if (status === 'loading') { + return ( + + + + + +

{loadingMessage}

+
+
+
+ ); + } + + return ( + + + } + > + + } + onChange={setImportFile} + data-test-subj="queryFilePicker" + /> + + + setImportMode(newValues)} + /> + + + ); + }; + + const importFile = async (isOverwriteConfirmed?: boolean) => { + setStatus('loading'); + setError(undefined); + try { + const results = await objectStorageClient.text.findAll(); + const currentText = results.sort((a, b) => a.createdAt - b.createdAt)[0]; + + if (jsonData?.text) { + if (importMode.overwrite) { + if (!isOverwriteConfirmed) { + setShowOverwriteModal(true); + return; + } else { + setLoadingMessage('Importing queries and overwriting existing ones...'); + const newObject = { + createdAt: Date.now(), + updatedAt: Date.now(), + text: jsonData.text, + }; + if (results.length) { + await objectStorageClient.text.update({ + ...currentText, + ...newObject, + }); + } else { + await objectStorageClient.text.create({ + ...newObject, + }); + } + } + toasts.addSuccess('Queries overwritten.'); + } else { + setLoadingMessage('Importing queries and merging with existing ones...'); + if (results.length) { + await objectStorageClient.text.update({ + ...currentText, + createdAt: Date.now(), + updatedAt: Date.now(), + text: currentText.text.concat( + `\n\n#Imported on ${moment(Date.now()).format(dateFormat)}\n\n${jsonData.text}` + ), + }); + toasts.addSuccess('Queries merged.'); + } + } + refresh(); + setLoadingMessage(undefined); + setStatus('idle'); + close(); + } else { + setStatus('error'); + setError( + i18n.translate('console.ImportFlyout.importFileErrorMessage', { + defaultMessage: 'The selected file is not valid. Please select a valid JSON file.', + }) + ); + return; + } + } catch (e) { + setStatus('error'); + setError(getErrorMessage(e)); + } + }; + + const onConfirm = () => { + setShowOverwriteModal(false); + importFile(true); + }; + + const onSkip = () => { + setShowOverwriteModal(false); + setStatus('idle'); + }; + + const renderFooter = () => { + return ( + + + + + + + + importFile(false)} + size="s" + fill + isLoading={status === 'loading'} + data-test-subj="importQueriesConfirmBtn" + > + + + + + ); + }; + + return ( + + + +

+ +

+
+
+ + + {renderError()} + {renderBody()} + + + {renderFooter()} + {showOverwriteModal && } +
+ ); +}; diff --git a/src/plugins/console/public/application/components/import_mode_control.test.tsx b/src/plugins/console/public/application/components/import_mode_control.test.tsx new file mode 100644 index 000000000000..219cc2f1c5fc --- /dev/null +++ b/src/plugins/console/public/application/components/import_mode_control.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ReactElement } from 'react'; +import { act } from '@testing-library/react-hooks'; +import { ShallowWrapper, shallow } from 'enzyme'; +import { nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { ImportModeControl } from './import_mode_control'; +import { EuiFormLegendProps, EuiRadioGroupProps } from '@elastic/eui'; + +const radioGroupIdentifier = 'EuiRadioGroup'; + +describe('ImportModeControl Component', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + const mockUpdateSelection = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + act(() => { + component = shallowWithIntl( + + ); + }); + await nextTick(); + component.update(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correclty', () => { + expect(component).toMatchSnapshot(); + }); + + it('should render the correct title in the fieldset legend', () => { + const legendText = 'Import options'; + const legend: EuiFormLegendProps = component.find('EuiFormFieldset').prop('legend'); + const legendTitle = shallow(legend?.children as ReactElement); + + expect(legendTitle.text()).toBe(legendText); + }); + + it('should display the correct labels for radio options', () => { + const componentProps = (component + .find(radioGroupIdentifier) + .props() as unknown) as EuiRadioGroupProps; + + // Check if the labels for radio options are displayed correctly + const radioOptions = componentProps.options; + expect(radioOptions[0].label).toBe('Merge with existing queries'); + expect(radioOptions[1].label).toBe('Overwrite existing queries'); + + // Check the initial selection (overwrite is false, so Merge with existing queries should be selected) + const selectedOptionId = component.find(radioGroupIdentifier).prop('idSelected'); + expect(selectedOptionId).toBe('overwriteDisabled'); + }); + + it('should call updateSelection when the selection is changed', async () => { + act(() => { + // @ts-ignore + component.find(radioGroupIdentifier).first().props().onChange('overwriteEnabled'); + }); + component.update(); + + // Expect that the updateSelection function has been called with the correct parameters + expect(mockUpdateSelection).toHaveBeenCalledWith({ overwrite: true }); + }); +}); diff --git a/src/plugins/console/public/application/components/import_mode_control.tsx b/src/plugins/console/public/application/components/import_mode_control.tsx new file mode 100644 index 000000000000..0b543a9c593c --- /dev/null +++ b/src/plugins/console/public/application/components/import_mode_control.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { EuiFormFieldset, EuiTitle, EuiRadioGroup } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface ImportModeControlProps { + initialValues: ImportMode; + updateSelection: (result: ImportMode) => void; +} + +export interface ImportMode { + overwrite: boolean; +} + +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate('console.importModeControl.overwrite.enabledLabel', { + defaultMessage: 'Overwrite existing queries', + }), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate('console.importModeControl.overwrite.disabledLabel', { + defaultMessage: 'Merge with existing queries', + }), +}; +const importOptionsTitle = i18n.translate('console.importModeControl.importOptionsTitle', { + defaultMessage: 'Import options', +}); + +export const ImportModeControl = ({ initialValues, updateSelection }: ImportModeControlProps) => { + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ overwrite, ...partial }); + }; + + const overwriteRadio = ( + onChange({ overwrite: id === overwriteEnabled.id })} + /> + ); + + return ( + + {importOptionsTitle} + + ), + }} + > + {overwriteRadio} + + ); +}; diff --git a/src/plugins/console/public/application/components/index.ts b/src/plugins/console/public/application/components/index.ts index d6cceb7c7ca8..dca2bb72d211 100644 --- a/src/plugins/console/public/application/components/index.ts +++ b/src/plugins/console/public/application/components/index.ts @@ -36,3 +36,4 @@ export { WelcomePanel } from './welcome_panel'; export { AutocompleteOptions, DevToolsSettingsModal } from './settings_modal'; export { HelpPanel } from './help_panel'; export { EditorContentSpinner } from './editor_content_spinner'; +export { ImportFlyout } from './import_flyout'; diff --git a/src/plugins/console/public/application/components/overwrite_modal.test.tsx b/src/plugins/console/public/application/components/overwrite_modal.test.tsx new file mode 100644 index 000000000000..46ce18d6534a --- /dev/null +++ b/src/plugins/console/public/application/components/overwrite_modal.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { OverwriteModal } from './overwrite_modal'; +import { act } from '@testing-library/react-hooks'; +import { ShallowWrapper } from 'enzyme'; + +const confirmModalIdentifier = 'EuiConfirmModal'; + +describe('OverwriteModal Component', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + const mockOnConfirm = jest.fn(); + const mockOnSkip = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + act(() => { + component = shallowWithIntl(); + }); + + component.update(); + }); + + it('should render correclty', () => { + expect(component).toMatchSnapshot(); + }); + + it('should call onConfirm when clicking the "Overwrite" button', () => { + act(() => { + // @ts-ignore + component.find(confirmModalIdentifier).first().props().onConfirm(); + }); + + component.update(); + + // Expect that the onConfirm function has been called + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + it('should call onSkip when clicking the "Skip" button', () => { + act(() => { + // @ts-ignore + component.find(confirmModalIdentifier).first().props().onCancel(); + }); + + component.update(); + + // Expect that the onSkip function has been called + expect(mockOnSkip).toHaveBeenCalled(); + }); + + it('should display the correct title and body text', () => { + // Find the title and body text elements + const componentProps = component.find(confirmModalIdentifier).first().props(); + // Find the

element inside the component + const paragraphElement = component.find('p'); + + // Expect the correct translations for title and body text + expect(componentProps.title).toBe('Confirm Overwrite'); + + // Check the text content of the

element + const expectedText = + 'Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "Merge with existing queries" option instead'; + expect(paragraphElement.text()).toEqual(expectedText); + }); +}); diff --git a/src/plugins/console/public/application/components/overwrite_modal.tsx b/src/plugins/console/public/application/components/overwrite_modal.tsx new file mode 100644 index 000000000000..5432a0fc86c2 --- /dev/null +++ b/src/plugins/console/public/application/components/overwrite_modal.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; + +export interface OverwriteModalProps { + onSkip: () => void; + onConfirm: () => void; +} +export const OverwriteModal = ({ onSkip, onConfirm }: OverwriteModalProps) => { + return ( + +

+ {i18n.translate('console.overwriteModal.body.conflict', { + defaultMessage: + 'Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "{option}" option instead', + values: { option: 'Merge with existing queries' }, + })} +

+ + ); +}; diff --git a/src/plugins/console/public/application/containers/main/get_top_nav.ts b/src/plugins/console/public/application/containers/main/get_top_nav.ts index e7eba5c580ac..cd21321993bb 100644 --- a/src/plugins/console/public/application/containers/main/get_top_nav.ts +++ b/src/plugins/console/public/application/containers/main/get_top_nav.ts @@ -34,9 +34,17 @@ interface Props { onClickHistory: () => void; onClickSettings: () => void; onClickHelp: () => void; + onClickExport: () => void; + onClickImport: () => void; } -export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp }: Props) { +export function getTopNavConfig({ + onClickHistory, + onClickSettings, + onClickHelp, + onClickExport, + onClickImport, +}: Props) { return [ { id: 'history', @@ -77,5 +85,31 @@ export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp } }, testId: 'consoleHelpButton', }, + { + id: 'export', + label: i18n.translate('console.topNav.exportTabLabel', { + defaultMessage: 'Export', + }), + description: i18n.translate('console.topNav.exportTabDescription', { + defaultMessage: 'Export', + }), + onClick: () => { + onClickExport(); + }, + testId: 'consoleExportButton', + }, + { + id: 'import', + label: i18n.translate('console.topNav.importTabLabel', { + defaultMessage: 'Import', + }), + description: i18n.translate('console.topNav.importTabDescription', { + defaultMessage: 'Import', + }), + onClick: () => { + onClickImport(); + }, + testId: 'consoleImportButton', + }, ]; } diff --git a/src/plugins/console/public/application/containers/main/main.tsx b/src/plugins/console/public/application/containers/main/main.tsx index 1967c14615bb..bbe5bd9856eb 100644 --- a/src/plugins/console/public/application/containers/main/main.tsx +++ b/src/plugins/console/public/application/containers/main/main.tsx @@ -28,8 +28,10 @@ * under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@osd/i18n'; +// @ts-expect-error +import { saveAs } from '@elastic/filesaver'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPageContent } from '@elastic/eui'; import { ConsoleHistory } from '../console_history'; import { Editor } from '../editor'; @@ -41,6 +43,7 @@ import { HelpPanel, SomethingWentWrongCallout, NetworkRequestStatusBar, + ImportFlyout, } from '../../components'; import { useServicesContext, useEditorReadContext, useRequestReadContext } from '../../contexts'; @@ -54,7 +57,7 @@ interface MainProps { export function Main({ dataSourceId }: MainProps) { const { - services: { storage }, + services: { storage, objectStorageClient }, } = useServicesContext(); const { ready: editorsReady } = useEditorReadContext(); @@ -71,6 +74,14 @@ export function Main({ dataSourceId }: MainProps) { const [showingHistory, setShowHistory] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [showImportFlyout, setShowImportFlyout] = useState(false); + + const onExport = async () => { + const results = await objectStorageClient.text.findAll(); + const senseData = results.sort((a, b) => a.createdAt - b.createdAt)[0]; + const blob = new Blob([JSON.stringify(senseData || {})], { type: 'application/json' }); + saveAs(blob, 'sense.json'); + }; const renderConsoleHistory = () => { return editorsReady ? setShowHistory(false)} /> : null; @@ -111,6 +122,8 @@ export function Main({ dataSourceId }: MainProps) { onClickHistory: () => setShowHistory(!showingHistory), onClickSettings: () => setShowSettings(true), onClickHelp: () => setShowHelp(!showHelp), + onClickExport: () => onExport(), + onClickImport: () => setShowImportFlyout(!showImportFlyout), })} /> @@ -152,6 +165,10 @@ export function Main({ dataSourceId }: MainProps) { ) : null} {showHelp ? setShowHelp(false)} /> : null} + + {showImportFlyout ? ( + setShowImportFlyout(false)} /> + ) : null} ); } diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 5e39565aca8a..bf12961989bc 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -28,8 +28,11 @@ * under the License. */ -import { notificationServiceMock } from '../../../../../core/public/mocks'; -import { httpServiceMock } from '../../../../../core/public/mocks'; +import { + notificationServiceMock, + uiSettingsServiceMock, + httpServiceMock, +} from '../../../../../core/public/mocks'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; @@ -53,7 +56,9 @@ export const serviceContextMock = { settings: new SettingsMock(storage), history: new HistoryMock(storage), notifications: notificationServiceMock.createSetupContract(), + uiSettings: uiSettingsServiceMock.createSetupContract(), objectStorageClient: {} as any, + http, }, docLinkVersion: 'NA', }; diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index fc9ab157f783..0e8398ea8b83 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -29,7 +29,7 @@ */ import React, { createContext, useContext, useEffect } from 'react'; -import { HttpSetup, NotificationsSetup } from 'opensearch-dashboards/public'; +import { HttpSetup, IUiSettingsClient, NotificationsSetup } from 'opensearch-dashboards/public'; import { History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; @@ -44,6 +44,7 @@ interface ContextServices { trackUiMetric: MetricsTracker; opensearchHostService: OpenSearchHostService; http: HttpSetup; + uiSettings: IUiSettingsClient; } export interface ContextValue { diff --git a/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts b/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts index b9be1c56d912..9c6ee873806e 100644 --- a/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts +++ b/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts @@ -32,6 +32,13 @@ import { useCallback, useEffect, useState } from 'react'; import { migrateToTextObjects } from './data_migration'; import { useEditorActionContext, useServicesContext } from '../../contexts'; +const DEFAULT_INPUT_VALUE = `GET _search +{ + "query": { + "match_all": {} + } +}`; + export const useDataInit = () => { const [error, setError] = useState(null); const [done, setDone] = useState(false); @@ -58,7 +65,7 @@ export const useDataInit = () => { const newObject = await objectStorageClient.text.create({ createdAt: Date.now(), updatedAt: Date.now(), - text: '', + text: DEFAULT_INPUT_VALUE, }); dispatch({ type: 'setCurrentTextObject', payload: newObject }); } else { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.test.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.test.ts index eaa171132785..fbeadf4cc7de 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.test.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.test.ts @@ -86,6 +86,27 @@ describe('test sendRequestToOpenSearch', () => { }); }); + it('test request success, json with long numerals', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const mockHttpResponse = createMockHttpResponse( + 200, + 'ok', + [['Content-Type', 'application/json, utf-8']], + { + 'long-max': longPositive, + 'long-min': longNegative, + } + ); + + jest.spyOn(opensearch, 'send').mockResolvedValue(mockHttpResponse); + sendRequestToOpenSearch(dummyArgs).then((result) => { + const value = (result as any)[0].response.value; + expect(value).toMatch(new RegExp(`"long-max": ${longPositive}[,\n]`)); + expect(value).toMatch(new RegExp(`"long-min": ${longNegative}[,\n]`)); + }); + }); + it('test request success, text', () => { const mockHttpResponse = createMockHttpResponse( 200, diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.ts index 4e1ae7267542..1cb992a7a99c 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/send_request_to_opensearch.ts @@ -28,6 +28,7 @@ * under the License. */ +import { stringify } from '@osd/std'; import { HttpFetchError, HttpSetup } from 'opensearch-dashboards/public'; import { extractDeprecationMessages } from '../../../lib/utils'; import { XJson } from '../../../../../opensearch_ui_shared/public'; @@ -116,7 +117,7 @@ export function sendRequestToOpenSearch( const contentType = httpResponse.response.headers.get('Content-Type') as BaseResponseType; let value = ''; if (contentType.includes('application/json')) { - value = JSON.stringify(httpResponse.body, null, 2); + value = stringify(httpResponse.body, null, 2); } else { value = httpResponse.body; } @@ -155,7 +156,7 @@ export function sendRequestToOpenSearch( if (httpError.body) { contentType = httpResponse.headers.get('Content-Type') as string; if (contentType?.includes('application/json')) { - value = JSON.stringify(httpError.body, null, 2); + value = stringify(httpError.body, null, 2); } else { value = httpError.body; } diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx index cc7be7e444f0..8955972d27a0 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx @@ -74,7 +74,10 @@ describe('useSendCurrentRequestToOpenSearch', () => { const { result } = renderHook(() => useSendCurrentRequestToOpenSearch(), { wrapper: contexts }); await act(() => result.current()); - expect(sendRequestToOpenSearch).toHaveBeenCalledWith({ requests: ['test'] }); + expect(sendRequestToOpenSearch).toHaveBeenCalledWith({ + requests: ['test'], + http: mockContextValue.services.http, + }); // Second call should be the request success const [, [requestSucceededCall]] = (dispatch as jest.Mock).mock.calls; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index c1a107ac500a..ac32909735b2 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -30,7 +30,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { HttpSetup, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, IUiSettingsClient, NotificationsSetup } from 'src/core/public'; import { ServicesContextProvider, EditorContextProvider, RequestContextProvider } from './contexts'; import { Main } from './containers'; import { createStorage, createHistory, createSettings } from '../services'; @@ -47,6 +47,7 @@ export interface BootDependencies { usageCollection?: UsageCollectionSetup; element: HTMLElement; dataSourceId?: string; + uiSettings: IUiSettingsClient; } export function renderApp({ @@ -57,6 +58,7 @@ export function renderApp({ element, http, dataSourceId, + uiSettings, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -85,6 +87,7 @@ export function renderApp({ trackUiMetric, objectStorageClient, http, + uiSettings, }, }} > diff --git a/src/plugins/console/public/lib/opensearch/opensearch.ts b/src/plugins/console/public/lib/opensearch/opensearch.ts index b0158945eb25..d1ba8797e474 100644 --- a/src/plugins/console/public/lib/opensearch/opensearch.ts +++ b/src/plugins/console/public/lib/opensearch/opensearch.ts @@ -57,6 +57,7 @@ export async function send( body: data, prependBasePath: true, asResponse: true, + withLongNumerals: true, }); } diff --git a/src/plugins/console/public/lib/utils/__tests__/__snapshots__/utils.test.ts.snap b/src/plugins/console/public/lib/utils/__tests__/__snapshots__/utils.test.ts.snap new file mode 100644 index 000000000000..48739d834c9a --- /dev/null +++ b/src/plugins/console/public/lib/utils/__tests__/__snapshots__/utils.test.ts.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Utils class formatRequestBodyDoc changed with indenting 1`] = ` +Object { + "changed": true, + "data": Array [ + "{ + \\"data\\": { + \\"long-max\\": 18014398509481982, + \\"long-min\\": -18014398509481982, + \\"num\\": 2 + } +}", + "{ + \\"'\\\\\\"key\\\\\\"'\\": \\"\\"\\"'oddly' 'quoted \\"value\\"'\\"\\"\\", + \\"obj\\": \\"\\"\\"{\\"nested\\": \\"inner-value\\", \\"inner-max\\": 18014398509481982\\"\\"\\" +}", + "{ + \\"version\\": true, + \\"size\\": 500, + \\"sort\\": [ + { + \\"time\\": { + \\"order\\": \\"desc\\", + \\"unmapped_type\\": \\"boolean\\" + } + } + ], + \\"aggs\\": { + \\"2\\": { + \\"date_histogram\\": { + \\"field\\": \\"time\\", + \\"fixed_interval\\": \\"30s\\", + \\"time_zone\\": \\"America/Los_Angeles\\", + \\"min_doc_count\\": 1 + } + } + }, + \\"_source\\": { + \\"excludes\\": [] + }, + \\"query\\": { + \\"bool\\": { + \\"filter\\": [ + { + \\"match_all\\": {} + }, + { + \\"range\\": { + \\"time\\": { + \\"gte\\": \\"2222-22-22T22:22:22.222Z\\", + \\"lte\\": \\"3333-33-33T33:33:33.333Z\\", + \\"format\\": \\"strict_date_optional_time\\" + } + } + } + ] + } + } +}", + "{ + \\"version\\": true, + \\"size\\": 500, + \\"sort\\": [ + { + \\"time\\": { + \\"order\\": \\"desc\\", + \\"unmapped_type\\": \\"boolean\\" + } + } + ], + \\"aggs\\": { + \\"2\\": { + \\"date_histogram\\": { + \\"field\\": \\"time\\", + \\"fixed_interval\\": \\"30s\\", + \\"time_zone\\": \\"America/Los_Angeles\\", + \\"min_doc_count\\": 1 + } + } + }, + \\"_source\\": { + \\"excludes\\": [] + }, + \\"query\\": { + \\"bool\\": { + \\"filter\\": [ + { + \\"match_all\\": {} + }, + { + \\"range\\": { + \\"time\\": { + \\"gte\\": \\"2222-22-22T22:22:22.222Z\\", + \\"lte\\": \\"3333-33-33T33:33:33.333Z\\", + \\"format\\": \\"strict_date_optional_time\\" + } + } + } + ] + } + } +}", + ], +} +`; + +exports[`Utils class formatRequestBodyDoc changed with no-indenting 1`] = ` +Object { + "changed": true, + "data": Array [ + "{\\"data\\":{\\"long-max\\":18014398509481982,\\"long-min\\":-18014398509481982,\\"num\\":2}}", + "{\\"'\\\\\\"key\\\\\\"'\\":\\"'oddly' 'quoted \\\\\\"value\\\\\\"'\\",\\"obj\\":\\"{\\\\\\"nested\\\\\\": \\\\\\"inner-value\\\\\\", \\\\\\"inner-max\\\\\\": 18014398509481982\\"}", + "{\\"version\\":true,\\"size\\":500,\\"sort\\":[{\\"time\\":{\\"order\\":\\"desc\\",\\"unmapped_type\\":\\"boolean\\"}}],\\"aggs\\":{\\"2\\":{\\"date_histogram\\":{\\"field\\":\\"time\\",\\"fixed_interval\\":\\"30s\\",\\"time_zone\\":\\"America/Los_Angeles\\",\\"min_doc_count\\":1}}},\\"_source\\":{\\"excludes\\":[]},\\"query\\":{\\"bool\\":{\\"filter\\":[{\\"match_all\\":{}},{\\"range\\":{\\"time\\":{\\"gte\\":\\"2222-22-22T22:22:22.222Z\\",\\"lte\\":\\"3333-33-33T33:33:33.333Z\\",\\"format\\":\\"strict_date_optional_time\\"}}}]}}}", + "{\\"version\\":true,\\"size\\":500,\\"sort\\":[{\\"time\\":{\\"order\\":\\"desc\\",\\"unmapped_type\\":\\"boolean\\"}}],\\"aggs\\":{\\"2\\":{\\"date_histogram\\":{\\"field\\":\\"time\\",\\"fixed_interval\\":\\"30s\\",\\"time_zone\\":\\"America/Los_Angeles\\",\\"min_doc_count\\":1}}},\\"_source\\":{\\"excludes\\":[]},\\"query\\":{\\"bool\\":{\\"filter\\":[{\\"match_all\\":{}},{\\"range\\":{\\"time\\":{\\"gte\\":\\"2222-22-22T22:22:22.222Z\\",\\"lte\\":\\"3333-33-33T33:33:33.333Z\\",\\"format\\":\\"strict_date_optional_time\\"}}}]}}}", + ], +} +`; diff --git a/src/plugins/console/public/lib/utils/__tests__/utils.test.ts b/src/plugins/console/public/lib/utils/__tests__/utils.test.ts index 7f5f0c0de53f..6004d963e27c 100644 --- a/src/plugins/console/public/lib/utils/__tests__/utils.test.ts +++ b/src/plugins/console/public/lib/utils/__tests__/utils.test.ts @@ -29,6 +29,7 @@ */ import * as utils from '../'; +import { stringify } from '@osd/std'; describe('Utils class', () => { test('extract deprecation messages', function () { @@ -104,4 +105,71 @@ describe('Utils class', () => { 'e", f"', ]); }); + + describe('formatRequestBodyDoc', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const sample = { + version: true, + size: 500, + sort: [{ time: { order: 'desc', unmapped_type: 'boolean' } }], + aggs: { + '2': { + date_histogram: { + field: 'time', + fixed_interval: '30s', + time_zone: 'America/Los_Angeles', + min_doc_count: 1, + }, + }, + }, + _source: { excludes: [] }, + query: { + bool: { + filter: [ + { + match_all: {}, + }, + { + range: { + time: { + gte: '2222-22-22T22:22:22.222Z', + lte: '3333-33-33T33:33:33.333Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }; + const objStringSample = { + [`'"key"'`]: `'oddly' 'quoted "value"'`, + obj: `{"nested": "inner-value", "inner-max": ${longPositive}`, + }; + const data = [ + stringify({ data: { 'long-max': longPositive, 'long-min': longNegative, num: 2 } }), + JSON.stringify(objStringSample), + JSON.stringify(sample), + JSON.stringify(sample, null, 3), + ]; + + test('changed with indenting', () => { + expect(utils.formatRequestBodyDoc(data, true)).toMatchSnapshot(); + }); + + test('changed with no-indenting', () => { + expect(utils.formatRequestBodyDoc(data, false)).toMatchSnapshot(); + }); + + test('unchanged with indenting', () => { + const result = utils.formatRequestBodyDoc([JSON.stringify(sample, null, 2)], true); + expect(result.changed).toStrictEqual(false); + }); + + test('unchanged with no-indenting', () => { + const result = utils.formatRequestBodyDoc([JSON.stringify(sample)], false); + expect(result.changed).toStrictEqual(false); + }); + }); }); diff --git a/src/plugins/console/public/lib/utils/index.ts b/src/plugins/console/public/lib/utils/index.ts index 93a0688ae725..7ea4cd9b893f 100644 --- a/src/plugins/console/public/lib/utils/index.ts +++ b/src/plugins/console/public/lib/utils/index.ts @@ -28,6 +28,7 @@ * under the License. */ +import { parse, stringify } from '@osd/std'; import _ from 'lodash'; import { XJson } from '../../../../opensearch_ui_shared/public'; @@ -42,7 +43,7 @@ export function textFromRequest(request: any) { } export function jsonToString(data: any, indent: boolean) { - return JSON.stringify(data, null, indent ? 2 : 0); + return stringify(data, null, indent ? 2 : 0); } export function formatRequestBodyDoc(data: string[], indent: boolean) { @@ -51,7 +52,7 @@ export function formatRequestBodyDoc(data: string[], indent: boolean) { for (let i = 0; i < data.length; i++) { const curDoc = data[i]; try { - let newDoc = jsonToString(JSON.parse(collapseLiteralStrings(curDoc)), indent); + let newDoc = jsonToString(parse(collapseLiteralStrings(curDoc)), indent); if (indent) { newDoc = expandLiteralStrings(newDoc); } diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index 5e1478875ec6..300d57d4b75d 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -68,6 +68,7 @@ export class ConsoleUIPlugin implements Plugin re.test(path))) { @@ -116,14 +116,24 @@ export const createHandler = ({ { headers: requestHeaders } ); - const { statusCode, body: responseContent, warnings } = opensearchResponse; + const { + statusCode, + body: responseContent, + warnings, + headers: responseHeaders, + } = opensearchResponse; if (method.toUpperCase() !== 'HEAD') { + /* If a response is a parse JSON object, we need to use a custom `stringify` to handle BigInt + * values. + */ + const isJSONResponse = responseHeaders?.['content-type']?.includes?.('application/json'); return response.custom({ statusCode: statusCode!, - body: responseContent, + body: isJSONResponse ? stringify(responseContent) : responseContent, headers: { warning: warnings || '', + ...(isJSONResponse ? { 'Content-Type': 'application/json; charset=utf-8' } : {}), }, }); } @@ -139,7 +149,7 @@ export const createHandler = ({ } catch (e: any) { const isResponseErrorFlag = isResponseError(e); if (!isResponseError) log.error(e); - const errorMessage = isResponseErrorFlag ? JSON.stringify(e.meta.body) : e.message; + const errorMessage = isResponseErrorFlag ? stringify(e.meta.body) : e.message; // core http route handler has special logic that asks for stream readable input to pass error opaquely const errorResponseBody = new Readable({ read() { diff --git a/src/plugins/console/server/routes/api/console/proxy/tests/body.test.ts b/src/plugins/console/server/routes/api/console/proxy/tests/body.test.ts index 95b243dd4734..59008fc81684 100644 --- a/src/plugins/console/server/routes/api/console/proxy/tests/body.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/tests/body.test.ts @@ -50,7 +50,9 @@ describe('Console Proxy Route', () => { const requestHandlerContextMock = coreMock.createRequestHandlerContext(); opensearchClient = requestHandlerContextMock.opensearch.client; - opensearchClient.asCurrentUser.transport.request.mockResolvedValueOnce(mockResponse); + opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mockResolvedValueOnce( + mockResponse + ); const handler = createHandler(getProxyRouteHandlerDeps({})); return handler( diff --git a/src/plugins/console/server/routes/api/console/proxy/tests/headers.test.ts b/src/plugins/console/server/routes/api/console/proxy/tests/headers.test.ts index a1964d160e2c..7a6b3f70f490 100644 --- a/src/plugins/console/server/routes/api/console/proxy/tests/headers.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/tests/headers.test.ts @@ -85,7 +85,9 @@ describe('Console Proxy Route', () => { opensearchDashboardsResponseFactory ); - const [[, opts]] = opensearchClient.asCurrentUser.transport.request.mock.calls; + const [ + [, opts], + ] = opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mock.calls; const headers = opts?.headers; expect(headers).to.have.property('x-forwarded-for'); expect(headers!['x-forwarded-for']).to.be('0.0.0.0'); diff --git a/src/plugins/console/server/routes/api/console/proxy/tests/params.test.ts b/src/plugins/console/server/routes/api/console/proxy/tests/params.test.ts index 80523c8031df..2e191425752a 100644 --- a/src/plugins/console/server/routes/api/console/proxy/tests/params.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/tests/params.test.ts @@ -75,7 +75,9 @@ describe('Console Proxy Route', () => { ); const mockResponse = opensearchServiceMock.createSuccessTransportRequestPromise('foo'); - opensearchClient.asCurrentUser.transport.request.mockResolvedValueOnce(mockResponse); + opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mockResolvedValueOnce( + mockResponse + ); const { status } = await handler( { core: requestHandlerContextMock, dataSource: {} as any }, @@ -93,7 +95,9 @@ describe('Console Proxy Route', () => { ); const mockResponse = opensearchServiceMock.createSuccessTransportRequestPromise('foo'); - opensearchClient.asCurrentUser.transport.request.mockResolvedValueOnce(mockResponse); + opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mockResolvedValueOnce( + mockResponse + ); const { status } = await handler( { core: requestHandlerContextMock, dataSource: {} as any }, diff --git a/src/plugins/console/server/routes/api/console/proxy/tests/query_string.test.ts b/src/plugins/console/server/routes/api/console/proxy/tests/query_string.test.ts index 9aefea1182cc..edb3ec6e6418 100644 --- a/src/plugins/console/server/routes/api/console/proxy/tests/query_string.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/tests/query_string.test.ts @@ -63,7 +63,9 @@ describe('Console Proxy Route', () => { describe('contains full url', () => { it('treats the url as a path', async () => { await request('GET', 'http://evil.com/test'); - const [[args]] = opensearchClient.asCurrentUser.transport.request.mock.calls; + const [ + [args], + ] = opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mock.calls; expect(args.path).toBe('/http://evil.com/test?pretty=true'); }); @@ -71,14 +73,18 @@ describe('Console Proxy Route', () => { describe('starts with a slash', () => { it('keeps as it is', async () => { await request('GET', '/index/id'); - const [[args]] = opensearchClient.asCurrentUser.transport.request.mock.calls; + const [ + [args], + ] = opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mock.calls; expect(args.path).toBe('/index/id?pretty=true'); }); }); describe(`doesn't start with a slash`, () => { it('adds slash to path before sending request', async () => { await request('GET', 'index/id'); - const [[args]] = opensearchClient.asCurrentUser.transport.request.mock.calls; + const [ + [args], + ] = opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mock.calls; expect(args.path).toBe('/index/id?pretty=true'); }); }); @@ -86,7 +92,9 @@ describe('Console Proxy Route', () => { describe(`contains query parameter`, () => { it('adds slash to path before sending request', async () => { await request('GET', '_cat/tasks?v'); - const [[args]] = opensearchClient.asCurrentUser.transport.request.mock.calls; + const [ + [args], + ] = opensearchClient.asCurrentUserWithLongNumeralsSupport.transport.request.mock.calls; expect(args.path).toBe('/_cat/tasks?v=&pretty=true'); }); }); diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx index 4133ef1f09b0..921e3894983b 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.tsx +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -73,7 +73,7 @@ export const Sidebar: FC = ({ children }) => { return ( - + { compressed={true} type="inline" > - + order_date {Object.keys(formattedRow).map((key) => ( - {key} + + {key} + diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index b3faaf0884b1..6c26763d5736 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -49,20 +49,22 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro if (isCallOutVisible) { callOut = ( - -

- To provide feedback,{' '} - - open an issue - - . -

-
+ + +

+ To provide feedback,{' '} + + open an issue + + . +

+
+
); } diff --git a/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx index a6a499a46398..d3bf5b6ffeb8 100644 --- a/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx +++ b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx @@ -153,7 +153,7 @@ export function DiscoverLegacy({ title="This Discover app version will be retired in OpenSearch version 2.11. To switch to the new Discover 2.0 version, turn on the New Discover toggle." iconType="alert" dismissible - onDismissible={closeCallOut} + onDismiss={closeCallOut} >

To provide feedback,{' '} diff --git a/src/plugins/opensearch_ui_shared/__packages_do_not_import__/xjson/json_xjson_translation_tools/index.ts b/src/plugins/opensearch_ui_shared/__packages_do_not_import__/xjson/json_xjson_translation_tools/index.ts index 9f42e669b280..9860be7800e4 100644 --- a/src/plugins/opensearch_ui_shared/__packages_do_not_import__/xjson/json_xjson_translation_tools/index.ts +++ b/src/plugins/opensearch_ui_shared/__packages_do_not_import__/xjson/json_xjson_translation_tools/index.ts @@ -28,12 +28,13 @@ * under the License. */ +import { parse, stringify } from '@osd/std'; import { extractJSONStringValues } from './parser'; export function collapseLiteralStrings(data: string) { const splitData = data.split(`"""`); for (let idx = 1; idx < splitData.length - 1; idx += 2) { - splitData[idx] = JSON.stringify(splitData[idx]); + splitData[idx] = stringify(splitData[idx]); } return splitData.join(''); } @@ -79,7 +80,7 @@ export function expandLiteralStrings(data: string) { (candidate[candidate.length - 2] === '"' && candidate[candidate.length - 3] === '\\'); if (!skip && candidate.match(/\\./)) { - result += `"""${JSON.parse(candidate)}"""`; + result += `"""${parse(candidate)}"""`; } else { result += candidate; } diff --git a/yarn.lock b/yarn.lock index 6b886da899a9..5d47951f5b8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2497,10 +2497,10 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@opensearch-project/opensearch@^2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-2.2.1.tgz#a400203afa6512ef73945663163a404763a10f5a" - integrity sha512-8zfQX1acL9eWG+ohIc9nJVT9LSqXCdbVEJs0rCPRtji3XF6ahzsiKmGNTeWLxCPDxWCjAIWq9t95xP3Y5Egi6Q== +"@opensearch-project/opensearch@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-2.3.1.tgz#3596e2f1f0615a7555102f6f941f0e0ec645c2cd" + integrity sha512-Kg8tddAx6sinStnNi6IeGilfvLWlonIxaRdVNiJcNPr1yMqd0c9TSegn18zKr0Pb0IM9xBIGBSkRPuh67ZN6Hw== dependencies: aws4 "^1.11.0" debug "^4.3.1"