diff --git a/package-lock.json b/package-lock.json index 8dadbda..31fa5a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@grafana/runtime": "10.0.3", "@grafana/ui": "10.0.3", "@reduxjs/toolkit": "^1.9.5", + "@uiw/react-codemirror": "^4.21.21", "lucene": "^2.1.1", "react": "17.0.2", "react-dom": "17.0.2", @@ -1873,6 +1874,93 @@ "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", "license": "MIT" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz", + "integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz", + "integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", + "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.5.tgz", + "integrity": "sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.1.tgz", + "integrity": "sha512-J2Xnn5lFYT1ZN/5ewEoMBCmLlL71lZ3mBdb7cUEuHhX2ESoSrNEucpsDXpX22EuTGm9LOgC9v4Z0wx+Ez8QmGA==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3631,6 +3719,27 @@ "resolved": "https://registry.npmjs.org/@leeoniya/ufuzzy/-/ufuzzy-1.0.6.tgz", "integrity": "sha512-7co2giTKNKESSEqW+nijF2cGG92WtlGkxFFq7dnwQTemS209JzTLODsnF1pS4KMr3S9xa7WheeCKfGVo5U7s6g==" }, + "node_modules/@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", + "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -5793,6 +5902,57 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.21.tgz", + "integrity": "sha512-+0i9dPrRSa8Mf0CvyrMvnAhajnqwsP3IMRRlaHDRgsSGL8igc4z7MhvUPn+7cWFAAqWzQRhMdMSWzo6/TEa3EA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.21.21.tgz", + "integrity": "sha512-PaxBMarufMWoR0qc5zuvBSt76rJ9POm9qoOaJbqRmnNL2viaF+d+Paf2blPSlm1JSnqn7hlRjio+40nZJ9TKzw==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.21.21", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -7124,6 +7284,20 @@ "node": ">= 0.12.0" } }, + "node_modules/codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -7342,6 +7516,11 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -16389,6 +16568,11 @@ "webpack": "^5.0.0" } }, + "node_modules/style-mod": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -17300,6 +17484,11 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -19237,6 +19426,87 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==" }, + "@codemirror/autocomplete": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz", + "integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "@codemirror/commands": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "@codemirror/language": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz", + "integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "@codemirror/lint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", + "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/search": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.5.tgz", + "integrity": "sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/state": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" + }, + "@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "@codemirror/view": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.1.tgz", + "integrity": "sha512-J2Xnn5lFYT1ZN/5ewEoMBCmLlL71lZ3mBdb7cUEuHhX2ESoSrNEucpsDXpX22EuTGm9LOgC9v4Z0wx+Ez8QmGA==", + "requires": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -20649,6 +20919,27 @@ "resolved": "https://registry.npmjs.org/@leeoniya/ufuzzy/-/ufuzzy-1.0.6.tgz", "integrity": "sha512-7co2giTKNKESSEqW+nijF2cGG92WtlGkxFFq7dnwQTemS209JzTLODsnF1pS4KMr3S9xa7WheeCKfGVo5U7s6g==" }, + "@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "requires": { + "@lezer/common": "^1.0.0" + } + }, + "@lezer/lr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", + "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "requires": { + "@lezer/common": "^1.0.0" + } + }, "@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -22223,6 +22514,33 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@uiw/codemirror-extensions-basic-setup": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.21.tgz", + "integrity": "sha512-+0i9dPrRSa8Mf0CvyrMvnAhajnqwsP3IMRRlaHDRgsSGL8igc4z7MhvUPn+7cWFAAqWzQRhMdMSWzo6/TEa3EA==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "@uiw/react-codemirror": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.21.21.tgz", + "integrity": "sha512-PaxBMarufMWoR0qc5zuvBSt76rJ9POm9qoOaJbqRmnNL2viaF+d+Paf2blPSlm1JSnqn7hlRjio+40nZJ9TKzw==", + "requires": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.21.21", + "codemirror": "^6.0.0" + } + }, "@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -23168,6 +23486,20 @@ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true }, + "codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -23333,6 +23665,11 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -29756,6 +30093,11 @@ "dev": true, "requires": {} }, + "style-mod": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" + }, "stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -30383,6 +30725,11 @@ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" }, + "w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 61d5fcc..5af26a3 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@grafana/runtime": "10.0.3", "@grafana/ui": "10.0.3", "@reduxjs/toolkit": "^1.9.5", + "@uiw/react-codemirror": "^4.21.21", "lucene": "^2.1.1", "react": "17.0.2", "react-dom": "17.0.2", diff --git a/src/LogContext/LogContextProvider.ts b/src/LogContext/LogContextProvider.ts new file mode 100644 index 0000000..744eb94 --- /dev/null +++ b/src/LogContext/LogContextProvider.ts @@ -0,0 +1,131 @@ +import { ReactNode } from 'react'; +import { lastValueFrom } from 'rxjs'; +import { QuickwitDataSource } from 'datasource'; +import { catchError } from 'rxjs/operators'; + +import { + CoreApp, + DataFrame, + DataQueryError, + DataQueryRequest, + dateTime, + LogRowModel, + rangeUtil, +} from '@grafana/data'; + +import { ElasticsearchQuery, Logs} from '../types'; + +import { LogContextUI } from 'LogContext/components/LogContextUI'; + +export interface LogRowContextOptions { + direction?: LogRowContextQueryDirection; + limit?: number; +} +export enum LogRowContextQueryDirection { + Backward = 'BACKWARD', + Forward = 'FORWARD', +} + +function createContextTimeRange(rowTimeEpochMs: number, direction: string) { + const offset = 7; + // For log context, we want to request data from 7 subsequent/previous indices + if (direction === LogRowContextQueryDirection.Forward) { + return { + from: dateTime(rowTimeEpochMs).utc(), + to: dateTime(rowTimeEpochMs).add(offset, 'hours').utc(), + }; + } else { + return { + from: dateTime(rowTimeEpochMs).subtract(offset, 'hours').utc(), + to: dateTime(rowTimeEpochMs).utc(), + }; + } +} + +export class LogContextProvider { + datasource: QuickwitDataSource; + contextQuery: string | null; + + constructor(datasource: QuickwitDataSource) { + this.datasource = datasource; + this.contextQuery = null; + } + private makeLogContextDataRequest = ( + row: LogRowModel, + options?: LogRowContextOptions, + origQuery?: ElasticsearchQuery + ) => { + const direction = options?.direction || LogRowContextQueryDirection.Backward; + const searchAfter = row.dataFrame.fields.find((f) => f.name === 'sort')?.values.get(row.rowIndex) ?? [row.timeEpochNs] + + const logQuery: Logs = { + type: 'logs', + id: '1', + settings: { + limit: options?.limit ? options?.limit.toString() : '10', + // Sorting of results in the context query + sortDirection: direction === LogRowContextQueryDirection.Backward ? 'desc' : 'asc', + // Used to get the next log lines before/after the current log line using sort field of selected log line + searchAfter: searchAfter, + }, + }; + + const query: ElasticsearchQuery = { + refId: `log-context-${row.dataFrame.refId}-${direction}`, + metrics: [logQuery], + query: this.contextQuery == null ? origQuery?.query : this.contextQuery, + }; + + const timeRange = createContextTimeRange(row.timeEpochMs, direction); + const range = { + from: timeRange.from, + to: timeRange.to, + raw: timeRange, + }; + + const interval = rangeUtil.calculateInterval(range, 1); + + const contextRequest: DataQueryRequest = { + requestId: `log-context-request-${row.dataFrame.refId}-${options?.direction}`, + targets: [query], + interval: interval.interval, + intervalMs: interval.intervalMs, + range, + scopedVars: {}, + timezone: 'UTC', + app: CoreApp.Explore, + startTime: Date.now(), + hideFromInspector: true, + }; + return contextRequest; + }; + + getLogRowContext = async ( + row: LogRowModel, + options?: LogRowContextOptions, + origQuery?: ElasticsearchQuery + ): Promise<{ data: DataFrame[] }> => { + const contextRequest = this.makeLogContextDataRequest(row, options, origQuery); + + return lastValueFrom( + this.datasource.query(contextRequest).pipe( + catchError((err) => { + const error: DataQueryError = { + message: 'Error during context query. Please check JS console logs.', + status: err.status, + statusText: err.message, + }; + throw error; + }) + ) + ); + }; + + getLogRowContextUi( + row: LogRowModel, + runContextQuery?: (() => void), + origQuery?: ElasticsearchQuery + ): ReactNode { + return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}, datasource:this.datasource})) + } +} diff --git a/src/LogContext/components/LogContextQueryBuilderSidebar.tsx b/src/LogContext/components/LogContextQueryBuilderSidebar.tsx new file mode 100644 index 0000000..34cba78 --- /dev/null +++ b/src/LogContext/components/LogContextQueryBuilderSidebar.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useMemo, useState } from "react"; +// import { Field } from '@grafana/data'; +import { useTheme2, CollapsableSection, Icon } from '@grafana/ui'; +import { LogContextProps } from "./LogContextUI"; +import { css, cx } from "@emotion/css"; +import { LuceneQuery } from "utils/lucene"; +import { LuceneQueryBuilder } from '@/QueryBuilder/lucene'; + + +// TODO : define sensible defaults here +const excludedFields = [ + '_source', + 'sort', + 'attributes', + 'attributes.message', + 'body', + 'body.message', + 'resource_attributes', + 'observed_timestamp_nanos', + 'timestamp_nanos', +]; + +function isPrimitive(valT: any) { + return ['string', 'number', "boolean", "undefined"].includes(valT) +} + +type FieldContingency = { [value: string]: { + count: number, pinned: boolean, active?: boolean +}}; +type Field = { + name: string, + contingency: FieldContingency +} + +function LogContextFieldSection(field: Field) { + const theme = useTheme2() + const hasActiveFilters = Object.entries(field.contingency).map(([_,entry])=>!!entry.active).reduce((a,b)=>a || b, false); + return( + + {hasActiveFilters && } + {field.name} + + ) +} + +type FieldItemProps = { + label: any, + contingency: { + count: number, + pinned: boolean + }, + active?: boolean, + onClick: () => void +} + +function LogContextFieldItem(props: FieldItemProps){ + const theme = useTheme2() + const lcAttributeItemStyle = css({ + display: "flex", + justifyContent: "space-between", + paddingLeft: "10px", + fontSize: theme.typography.bodySmall.fontSize, + "&[data-active=true]": { + backgroundColor: theme.colors.primary.transparent, + }, + "&:hover": { + backgroundColor: theme.colors.secondary.shade, + } + }); + + const formatLabel = (value: any)=> { + let shouldEmphasize = false; + let label = `${value}`; + + if (value === null || value === '' || value === undefined){ + shouldEmphasize = true; + } + if (value === '') { + label = '' + } + return (shouldEmphasize ? {label} : label); + } + + return ( + + { formatLabel(props.label) } + {props.contingency.pinned && }{props.contingency.count} + + ) +} + +const lcSidebarStyle = css` + width: 300px; + min-width: 300px; + flex-shrink: 0; + overflow-y: scroll; + padding-right: 1rem; +` + +type QueryBuilderProps = { + builder: LuceneQueryBuilder, + searchableFields: any[], + updateQuery: (query: LuceneQuery) => void +} + +export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) { + + const {row, builder, updateQuery, searchableFields} = props; + const [fields, setFields] = useState([]); + + const filteredFields = useMemo(() => { + const searchableFieldsNames = searchableFields.map(f=>f.text); + return row.dataFrame.fields + .filter(f=>searchableFieldsNames.includes(f.name)) + // exclude some low-filterability fields + .filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type)) + // sort fields by name + .sort((f1, f2)=> (f1.name>f2.name ? 1 : -1)) + }, [row, searchableFields]); + + useEffect(() => { + const fields = filteredFields + .map((f) => { + const contingency: FieldContingency = {}; + f.values.forEach((value, i) => { + if (!contingency[value]) { + contingency[value] = { + count: 0, + pinned: false, + active: builder.parsedQuery ? !!builder.parsedQuery.findFilter(f.name, `${value}`) : false + } + } + contingency[value].count += 1; + if (i === row.rowIndex) { + contingency[value].pinned = true; + } + }); + return { name: f.name, contingency }; + }) + + setFields(fields); + }, [filteredFields, row.rowIndex, builder.parsedQuery]); + + + const selectQueryFilter = (key: string, value: string): void => { + // Compute mutation to apply to the query and send to parent + // check if that filter is in the query + if (!builder.parsedQuery) { return; } + + const newParsedQuery = ( + builder.parsedQuery.hasFilter(key, value) + ? builder.parsedQuery.removeFilter(key, value) + : builder.parsedQuery.addFilter(key, value) + ) + + if (newParsedQuery) { + updateQuery(newParsedQuery); + } + } + + const renderFieldSection = (field: Field)=>{ + return ( + div { flex-grow:1; }` } + isOpen={false} key="log-attribute-field-{field.name}" + contentClassName={cx(css`margin:0; padding:0`)}> +
+ + {field.contingency && Object.entries(field.contingency) + .sort(([na, ca], [nb, cb])=>(cb.count - ca.count)) + .map(([fieldValue, contingency], i) => ( + {selectQueryFilter(field.name, fieldValue)}} + active={contingency.active} + /> + )) + } +
+
+ ) + } + + return ( +
+ {fields && fields.map((field) => { + return( renderFieldSection(field) ); + }) }
+ ); +} diff --git a/src/LogContext/components/LogContextUI.tsx b/src/LogContext/components/LogContextUI.tsx new file mode 100644 index 0000000..e60fa0c --- /dev/null +++ b/src/LogContext/components/LogContextUI.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { LogRowModel } from '@grafana/data'; +import { ElasticsearchQuery as DataQuery } from '../../types'; +import { LuceneQueryEditor } from "../../components/LuceneQueryEditor"; + +import { css } from "@emotion/css"; +import { Button } from "@grafana/ui"; +import { useQueryBuilder } from '@/QueryBuilder/lucene'; +import { LogContextQueryBuilderSidebar } from "./LogContextQueryBuilderSidebar"; +import { DatasourceContext } from "components/QueryEditor/ElasticsearchQueryContext"; +import { QuickwitDataSource } from "datasource"; +import { useDatasourceFields } from "datasource.utils"; + +const logContextUiStyle = css` + display: flex; + gap: 1rem; + width: 100%; + height: 200px; +` + +export interface LogContextProps { + row: LogRowModel, + runContextQuery?: (() => void) + origQuery?: DataQuery +} +export interface LogContextUIProps extends LogContextProps { + datasource: QuickwitDataSource, + updateQuery: (query: string) => void +} + +export function LogContextUI(props: LogContextUIProps ){ + const builder = useQueryBuilder(); + const {query, parsedQuery, setQuery, setParsedQuery} = builder; + const [canRunQuery, setCanRunQuery] = useState(false); + const { origQuery, updateQuery, runContextQuery } = props; + const {fields, getSuggestions} = useDatasourceFields(props.datasource); + + useEffect(()=>{ + setQuery(origQuery?.query || '') + }, [setQuery, origQuery]) + + useEffect(()=>{ + setCanRunQuery(!parsedQuery.parseError) + }, [parsedQuery, setCanRunQuery]) + + const runQuery = useCallback(()=>{ + if (runContextQuery){ + updateQuery(query); + runContextQuery(); + } + }, [query, runContextQuery, updateQuery]) + + const ActionBar = useMemo(()=>( +
+ + + +
+ ), [setQuery, canRunQuery, origQuery, runQuery]) + + return ( +
+ + +
+ {ActionBar} + +
+
+
+ ); +} diff --git a/src/QueryBuilder.ts b/src/QueryBuilder/elastic.ts similarity index 98% rename from src/QueryBuilder.ts rename to src/QueryBuilder/elastic.ts index 81012d9..f66c64b 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder/elastic.ts @@ -1,6 +1,6 @@ import { TermsQuery, -} from './types'; +} from '../types'; export class ElasticQueryBuilder { timeField: string; diff --git a/src/QueryBuilder/lucene.ts b/src/QueryBuilder/lucene.ts new file mode 100644 index 0000000..1920ae0 --- /dev/null +++ b/src/QueryBuilder/lucene.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from 'react'; +import * as lucene from "@/utils/lucene"; +import { LuceneQuery as QueryBuilder } from '@/utils/lucene'; + +export type LuceneQueryBuilder = { + query: string, + parsedQuery: lucene.LuceneQuery, + setQuery: (query: string) => void + setParsedQuery: (query: lucene.LuceneQuery) => void +} + +export function useQueryBuilder() { + const [parsedQuery, _setParsedQuery] = useState(QueryBuilder.parse("")); + const [query, _setQuery] = useState(""); + + const setQuery = useCallback((query: string) => { + _setQuery(query); + _setParsedQuery(QueryBuilder.parse(query)); + }, [_setQuery, _setParsedQuery]); + + const setParsedQuery = useCallback((query: QueryBuilder) => { + _setParsedQuery(query); + _setQuery(query.toString()); + }, [_setQuery, _setParsedQuery]); + + return { + query, + parsedQuery, + setQuery, + setParsedQuery, + } +} diff --git a/src/components/LuceneQueryEditor.tsx b/src/components/LuceneQueryEditor.tsx new file mode 100644 index 0000000..f0bb3c8 --- /dev/null +++ b/src/components/LuceneQueryEditor.tsx @@ -0,0 +1,61 @@ +import React, { useRef, useCallback } from "react"; +import { css } from "@emotion/css"; + +import { LuceneQueryBuilder } from '@/QueryBuilder/lucene'; + +import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import {linter, Diagnostic, lintGutter} from "@codemirror/lint" +import {autocompletion, CompletionContext} from "@codemirror/autocomplete" + + +export type LuceneQueryEditorProps = { + placeholder?: string, + builder: LuceneQueryBuilder, + autocompleter: (word: string) => any, + onChange: (query: string) => void +} + +export function LuceneQueryEditor(props: LuceneQueryEditorProps){ + const editorRef = useRef(null) + + const queryLinter = linter( view => { + let diagnostics: Diagnostic[] = []; + + const error = props.builder.parsedQuery?.parseError + if (error) { + diagnostics.push({ + severity: "error", + message: error.message, + from: view.state.doc.line(error.location.start.line).from + error.location.start.column -1, + to: view.state.doc.line(error.location.end.line).from + error.location.end.column -1, + }) ; + } + return diagnostics + }) + + + const {autocompleter} = props; + const datasourceCompletions = useCallback(async (context: CompletionContext)=>{ + let word = context.matchBefore(/\S*/); + if (!word){ return null } + const suggestions = await autocompleter(word?.text); + return { + from: word.from + suggestions.from, + options: suggestions.options + } + },[autocompleter]) + + + const autocomplete = autocompletion({ override: [datasourceCompletions] }) + + return (); +} diff --git a/src/components/QueryEditor/ElasticsearchQueryContext.tsx b/src/components/QueryEditor/ElasticsearchQueryContext.tsx index e3d2e7e..34e77c5 100644 --- a/src/components/QueryEditor/ElasticsearchQueryContext.tsx +++ b/src/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -1,4 +1,4 @@ -import React, { Context, createContext, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { CoreApp, TimeRange } from '@grafana/data'; @@ -9,10 +9,16 @@ import { ElasticsearchQuery } from '@/types'; import { createReducer as createBucketAggsReducer } from './BucketAggregationsEditor/state/reducer'; import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer'; import { aliasPatternReducer, queryReducer, initQuery, initExploreQuery } from './state'; +import { getHook } from 'utils/context'; -const DatasourceContext = createContext(undefined); -const QueryContext = createContext(undefined); -const RangeContext = createContext(undefined); +export const RangeContext = createContext(undefined); +export const useRange = getHook(RangeContext); + +export const QueryContext = createContext(undefined); +export const useQuery = getHook(QueryContext); + +export const DatasourceContext = createContext(undefined); +export const useDatasource = getHook(DatasourceContext); interface Props { query: ElasticsearchQuery; @@ -85,21 +91,3 @@ export const ElasticsearchProvider = ({ ); }; - -interface GetHook { - (context: Context): () => NonNullable; -} - -const getHook: GetHook = (c) => () => { - const contextValue = useContext(c); - - if (!contextValue) { - throw new Error('use ElasticsearchProvider first.'); - } - - return contextValue; -}; - -export const useQuery = getHook(QueryContext); -export const useDatasource = getHook(DatasourceContext); -export const useRange = getHook(RangeContext); diff --git a/src/components/QueryEditor/index.tsx b/src/components/QueryEditor/index.tsx index f627bc4..c503fe8 100644 --- a/src/components/QueryEditor/index.tsx +++ b/src/components/QueryEditor/index.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { createContext, useCallback, useEffect } from 'react'; -import { CoreApp, getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; -import { InlineLabel, QueryField, useStyles2 } from '@grafana/ui'; +import { CoreApp, Field, getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { InlineLabel, useStyles2 } from '@grafana/ui'; import { ElasticDatasource } from '@/datasource'; import { useNextId } from '@/hooks/useNextId'; @@ -11,13 +11,18 @@ import { useDispatch } from '@/hooks/useStatelessReducer'; import { ElasticsearchQuery } from '@/types'; import { BucketAggregationsEditor } from './BucketAggregationsEditor'; -import { ElasticsearchProvider } from './ElasticsearchQueryContext'; +import { ElasticsearchProvider, useDatasource } from './ElasticsearchQueryContext'; import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { changeQuery } from './state'; import { QuickwitOptions } from '../../quickwit'; import { QueryTypeSelector } from './QueryTypeSelector'; +import { useQueryBuilder } from '@/QueryBuilder/lucene'; +import { getHook } from 'utils/context'; +import { LuceneQueryEditor } from '@/components/LuceneQueryEditor'; +import { useDatasourceFields } from 'datasource.utils'; + export type ElasticQueryEditorProps = QueryEditorProps; export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range, app }: ElasticQueryEditorProps) => { @@ -38,31 +43,39 @@ export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range, ap const getStyles = (theme: GrafanaTheme2) => ({ root: css` display: flex; + margin: 0 ${theme.spacing(0.5)} ${theme.spacing(0.5)} 0; `, queryItem: css` flex-grow: 1; - margin: 0 ${theme.spacing(0.5)} ${theme.spacing(0.5)} 0; `, }); +const SearchableFieldsContext = createContext(undefined) +export const useSearchableFields = getHook(SearchableFieldsContext) + interface Props { value: ElasticsearchQuery; } export const ElasticSearchQueryField = ({ value, onChange }: { value?: string; onChange: (v: string) => void }) => { const styles = useStyles2(getStyles); + const builder = useQueryBuilder(); + const {setQuery} = builder; + const datasource = useDatasource() + const { getSuggestions } = useDatasourceFields(datasource); + + useEffect(()=>{ + setQuery(value || '') + }, [setQuery, value]) + + const onEditorChange = useCallback((query: string)=>{ + setQuery(query); + onChange(query) + },[setQuery, onChange]) return (
- {}} - onChange={onChange} - placeholder="Enter a lucene query" - portalOrigin="elasticsearch" - /> +
); }; diff --git a/src/dataquery.gen.ts b/src/dataquery.gen.ts index 4cdc060..efe68c0 100644 --- a/src/dataquery.gen.ts +++ b/src/dataquery.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -import { DataQuery } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; export const DataQueryModelVersion = Object.freeze([0, 0]); diff --git a/src/datasource.ts b/src/datasource.ts index 81ae5d2..190f6a1 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -5,10 +5,8 @@ import { catchError, mergeMap, map } from 'rxjs/operators'; import { AbstractQuery, AdHocVariableFilter, - CoreApp, DataFrame, DataLink, - DataQueryError, DataQueryRequest, DataQueryResponse, DataSourceApi, @@ -17,7 +15,6 @@ import { DataSourceWithLogsContextSupport, DataSourceWithQueryImportSupport, DataSourceWithSupplementaryQueriesSupport, - dateTime, FieldColorModeId, FieldType, getDefaultTimeRange, @@ -28,19 +25,18 @@ import { LogsVolumeType, MetricFindValue, QueryFixAction, - rangeUtil, ScopedVars, SupplementaryQueryType, TimeRange, } from '@grafana/data'; -import { BucketAggregation, DataLinkConfig, ElasticsearchQuery, Field as QuickwitField, FieldMapping, IndexMetadata, Logs, TermsQuery, FieldCapabilitiesResponse } from './types'; +import { BucketAggregation, DataLinkConfig, ElasticsearchQuery, Field as QuickwitField, FieldMapping, IndexMetadata, TermsQuery, FieldCapabilitiesResponse } from './types'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv, getDataSourceSrv } from '@grafana/runtime'; -import { LogRowContextOptions, LogRowContextQueryDirection, QuickwitOptions } from 'quickwit'; -import { ElasticQueryBuilder } from 'QueryBuilder'; +import { QuickwitOptions } from 'quickwit'; +import { ElasticQueryBuilder } from 'QueryBuilder/elastic'; import { colors } from '@grafana/ui'; import { BarAlignment, DataQuery, GraphDrawStyle, StackingMode } from '@grafana/schema'; @@ -52,11 +48,19 @@ import ElasticsearchLanguageProvider from 'LanguageProvider'; import { ReactNode } from 'react'; import { extractJsonPayload, fieldTypeMap } from 'utils'; import { addAddHocFilter } from 'modifyQuery'; +import { LogContextProvider, LogRowContextOptions } from './LogContext/LogContextProvider'; export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; export type ElasticDatasource = QuickwitDataSource; +type FieldCapsSpec = { + aggregatable?: boolean, + searchable?: boolean, + type?: string[], + _range?: TimeRange +} + export class QuickwitDataSource extends DataSourceWithBackend implements @@ -73,6 +77,8 @@ export class QuickwitDataSource dataLinks: DataLinkConfig[]; languageProvider: ElasticsearchLanguageProvider; + private logContextProvider: LogContextProvider; + constructor( instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv() @@ -117,6 +123,7 @@ export class QuickwitDataSource this.logLevelField = settingsData.logLevelField || ''; this.dataLinks = settingsData.dataLinks || []; this.languageProvider = new ElasticsearchLanguageProvider(this); + this.logContextProvider = new LogContextProvider(this); } query(request: DataQueryRequest): Observable { @@ -368,21 +375,21 @@ export class QuickwitDataSource ); } - getFields(aggregatable?: boolean, type?: string[], _range?: TimeRange): Observable { + getFields(spec: FieldCapsSpec={}): Observable { // TODO: use the time range. return from(this.getResource('_elastic/' + this.index + '/_field_caps')).pipe( map((field_capabilities_response: FieldCapabilitiesResponse) => { const shouldAddField = (field: any) => { - if (aggregatable === undefined) { - return true; + if (spec.aggregatable !== undefined && field.aggregatable !== spec.aggregatable) { + return false } - if (aggregatable !== undefined && field.aggregatable !== aggregatable) { - return false; + if (spec.searchable !== undefined && field.searchable !== spec.searchable){ + return false } - if (type?.length === 0) { - return true; + if (spec.type && spec.type.length !== 0 && !(spec.type.includes(field.type) || spec.type.includes(fieldTypeMap[field.type]))) { + return false } - return type?.includes(field.type) || type?.includes(fieldTypeMap[field.type]); + return true }; const fieldCapabilities = Object.entries(field_capabilities_response.fields) .flatMap(([field_name, field_capabilities]) => { @@ -412,8 +419,8 @@ export class QuickwitDataSource /** * Get tag keys for adhoc filters */ - getTagKeys() { - return lastValueFrom(this.getFields()); + getTagKeys(spec?: FieldCapsSpec) { + return lastValueFrom(this.getFields(spec)); } /** @@ -475,75 +482,27 @@ export class QuickwitDataSource return text; } - private makeLogContextDataRequest = (row: LogRowModel, options?: LogRowContextOptions) => { - const direction = options?.direction || LogRowContextQueryDirection.Backward; - const searchAfter = row.dataFrame.fields.find((f) => f.name === 'sort')?.values.get(row.rowIndex) ?? [row.timeEpochNs] - - const logQuery: Logs = { - type: 'logs', - id: '1', - settings: { - limit: options?.limit ? options?.limit.toString() : '10', - // Sorting of results in the context query - sortDirection: direction === LogRowContextQueryDirection.Backward ? 'desc' : 'asc', - // Used to get the next log lines before/after the current log line using sort field of selected log line - searchAfter: searchAfter, - }, - }; - - const query: ElasticsearchQuery = { - refId: `log-context-${row.dataFrame.refId}-${direction}`, - metrics: [logQuery], - query: '', - }; - - const timeRange = createContextTimeRange(row.timeEpochMs, direction); - const range = { - from: timeRange.from, - to: timeRange.to, - raw: timeRange, - }; - - const interval = rangeUtil.calculateInterval(range, 1); - - const contextRequest: DataQueryRequest = { - requestId: `log-context-request-${row.dataFrame.refId}-${options?.direction}`, - targets: [query], - interval: interval.interval, - intervalMs: interval.intervalMs, - range, - scopedVars: {}, - timezone: 'UTC', - app: CoreApp.Explore, - startTime: Date.now(), - hideFromInspector: true, - }; - return contextRequest; - }; - - getLogRowContext = async (row: LogRowModel, options?: LogRowContextOptions): Promise<{ data: DataFrame[] }> => { - const contextRequest = this.makeLogContextDataRequest(row, options); - - return lastValueFrom( - this.query(contextRequest).pipe( - catchError((err) => { - const error: DataQueryError = { - message: 'Error during context query. Please check JS console logs.', - status: err.status, - statusText: err.message, - }; - throw error; - }) - ) - ); - }; + // Log Context + // NOTE : deprecated since grafana-data 10.3 showContextToggle(row?: LogRowModel | undefined): boolean { return true; } - getLogRowContextUi?(row: LogRowModel, runContextQuery?: (() => void) | undefined): ReactNode { - return true; + getLogRowContext = async ( + row: LogRowModel, + options?: LogRowContextOptions, + origQuery?: ElasticsearchQuery + ): Promise<{ data: DataFrame[] }> => { + return await this.logContextProvider.getLogRowContext(row, options, origQuery); + } + + getLogRowContextUi( + row: LogRowModel, + runContextQuery?: (() => void), + origQuery?: ElasticsearchQuery + ): ReactNode { + return this.logContextProvider.getLogRowContextUi(row, runContextQuery, origQuery); } /** @@ -562,7 +521,7 @@ export class QuickwitDataSource if (query) { if (parsedQuery.find === 'fields') { parsedQuery.type = this.interpolateLuceneQuery(parsedQuery.type); - return lastValueFrom(this.getFields(true, parsedQuery.type, range)); + return lastValueFrom(this.getFields({aggregatable:true, type:parsedQuery.type, _range:range})); } if (parsedQuery.find === 'terms') { parsedQuery.field = this.interpolateLuceneQuery(parsedQuery.field); @@ -909,19 +868,3 @@ function generateDataLink(linkConfig: DataLinkConfig): DataLink { }; } } - -function createContextTimeRange(rowTimeEpochMs: number, direction: string) { - const offset = 7; - // For log context, we want to request data from 7 subsequent/previous indices - if (direction === LogRowContextQueryDirection.Forward) { - return { - from: dateTime(rowTimeEpochMs).utc(), - to: dateTime(rowTimeEpochMs).add(offset, 'hours').utc(), - }; - } else { - return { - from: dateTime(rowTimeEpochMs).subtract(offset, 'hours').utc(), - to: dateTime(rowTimeEpochMs).utc(), - }; - } -} diff --git a/src/datasource.utils.ts b/src/datasource.utils.ts new file mode 100644 index 0000000..fbe5ef3 --- /dev/null +++ b/src/datasource.utils.ts @@ -0,0 +1,53 @@ +import { QuickwitDataSource } from "datasource"; +import { useState, useEffect, useCallback } from "react"; +import{ MetricFindValue } from '@grafana/data'; + +/** + * Provide suggestions based on datasource fields + */ + +export type Suggestion = { + from: number, + options: Array<{ + label: string, + detail?: string, + type?: string, + }> +} + +export function useDatasourceFields(datasource: QuickwitDataSource) { + const [fields, setFields] = useState([]); + + useEffect(() => { + if (datasource.getTagKeys) { + datasource.getTagKeys({ searchable: true }).then(setFields); + } + }, [datasource, setFields]); + + const getSuggestions = useCallback(async (word: string): Promise => { + let suggestions: Suggestion = { from: 0, options: [] }; + + const wordIsField = word.match(/([\w\.]+):"?(\S*)/); + if (wordIsField?.length) { + const [_match, fieldName, _fieldValue] = wordIsField; + const candidateValues = await datasource.getTagValues({ key: fieldName }); + suggestions.from = fieldName.length + 1; // Replace only the value part + suggestions.options = candidateValues.map(v => ({ + type: 'text', + label: typeof v.text === 'number' ? `${v.text}` : `"${v.text}"` + })); + } else { + const candidateFields = fields; + suggestions.from = 0; + suggestions.options = candidateFields.map(f => ({ + type: 'variable', + label: f.text, + detail: `${f.value}` + })); + } + return suggestions; + + }, [datasource, fields]); + + return {fields, getSuggestions} +} diff --git a/src/hooks/useFields.test.tsx b/src/hooks/useFields.test.tsx index aa801e0..741e041 100644 --- a/src/hooks/useFields.test.tsx +++ b/src/hooks/useFields.test.tsx @@ -11,6 +11,8 @@ import { ElasticsearchQuery, MetricAggregationType, BucketAggregationType } from import { useFields } from './useFields'; import { renderHook } from '@testing-library/react-hooks'; + + describe('useFields hook', () => { // TODO: If we move the field type to the configuration objects as described in the hook's source // we can stop testing for getField to be called with the correct parameters. @@ -48,12 +50,12 @@ describe('useFields hook', () => { { wrapper, initialProps: 'cardinality' } ); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, [], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:[], _range:timeRange}); // All other metric aggregations only work on numbers rerender('avg'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange}); // // BUCKET AGGREGATIONS @@ -61,26 +63,26 @@ describe('useFields hook', () => { // Date Histrogram only works on dates rerender('date_histogram'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, ['date'], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['date'], _range:timeRange}); // Histrogram only works on numbers rerender('histogram'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange}); // Geohash Grid only works on geo_point data rerender('geohash_grid'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, ['geo_point'], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['geo_point'], _range:timeRange}); // All other bucket aggregation work on any kind of data rerender('terms'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, [], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:[], _range:timeRange}); // top_metrics work on only on numeric data in 7.7 rerender('top_metrics'); result.current(); - expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange); + expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange}); }); }); diff --git a/src/hooks/useFields.ts b/src/hooks/useFields.ts index ffc6db6..422161d 100644 --- a/src/hooks/useFields.ts +++ b/src/hooks/useFields.ts @@ -62,7 +62,7 @@ export const useFields = (type: AggregationType | string[]) => { return async (q?: string) => { // _mapping doesn't support filtering, we avoid sending a request everytime q changes if (!rawFields) { - rawFields = await lastValueFrom(datasource.getFields(true, filter, range)); + rawFields = await lastValueFrom(datasource.getFields({aggregatable:true, type:filter, _range:range})); } return rawFields.filter(({ text }) => q === undefined || text.includes(q)).map(toSelectableValue); diff --git a/src/modifyQuery.ts b/src/modifyQuery.ts index c9d98fc..495fc42 100644 --- a/src/modifyQuery.ts +++ b/src/modifyQuery.ts @@ -1,85 +1,6 @@ -import { isEqual } from 'lodash'; -import lucene, { AST, BinaryAST, LeftOnlyAST, NodeTerm } from 'lucene'; - +import { escapeFilter, escapeFilterValue, concatenate, LuceneQuery } from 'utils/lucene'; import { AdHocVariableFilter } from '@grafana/data'; -type ModifierType = '' | '-'; - -/** - * Checks for the presence of a given label:"value" filter in the query. - */ -export function queryHasFilter(query: string, key: string, value: string, modifier: ModifierType = ''): boolean { - return findFilterNode(query, key, value, modifier) !== null; -} - -/** - * Given a query, find the NodeTerm that matches the given field and value. - */ -export function findFilterNode( - query: string, - key: string, - value: string, - modifier: ModifierType = '' -): NodeTerm | null { - const field = `${modifier}${lucene.term.escape(key)}`; - value = lucene.phrase.escape(value); - let ast: AST | null = parseQuery(query); - if (!ast) { - return null; - } - - return findNodeInTree(ast, field, value); -} - -function findNodeInTree(ast: AST, field: string, value: string): NodeTerm | null { - // {} - if (Object.keys(ast).length === 0) { - return null; - } - // { left: {}, right: {} } or { left: {} } - if (isAST(ast.left)) { - return findNodeInTree(ast.left, field, value); - } - if (isNodeTerm(ast.left) && ast.left.field === field && ast.left.term === value) { - return ast.left; - } - if (isLeftOnlyAST(ast)) { - return null; - } - if (isNodeTerm(ast.right) && ast.right.field === field && ast.right.term === value) { - return ast.right; - } - if (isBinaryAST(ast.right)) { - return findNodeInTree(ast.right, field, value); - } - return null; -} - -/** - * Adds a label:"value" expression to the query. - */ -export function addFilterToQuery(query: string, key: string, value: string, modifier: ModifierType = ''): string { - if (queryHasFilter(query, key, value, modifier)) { - return query; - } - - key = escapeFilter(key); - value = escapeFilterValue(value); - const filter = `${modifier}${key}:"${value}"`; - - return concatenate(query, filter); -} - -/** - * Merge a query with a filter. - */ -function concatenate(query: string, filter: string, condition = 'AND'): string { - if (!filter) { - return query; - } - return query.trim() === '' ? filter : `${query} ${condition} ${filter}`; -} - /** * Adds a label:"value" expression to the query. */ @@ -96,7 +17,7 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str const equalityFilters = ['=', '!=']; if (equalityFilters.includes(filter.operator)) { - return addFilterToQuery(query, filter.key, filter.value, filter.operator === '=' ? '' : '-'); + return LuceneQuery.parse(query).addFilter(filter.key, filter.value, filter.operator === '=' ? '' : '-').toString(); } /** * Keys and values in ad hoc filters may contain characters such as @@ -121,127 +42,3 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str } return concatenate(query, addHocFilter); } - -/** - * Removes a label:"value" expression from the query. - */ -export function removeFilterFromQuery(query: string, key: string, value: string, modifier: ModifierType = ''): string { - const node = findFilterNode(query, key, value, modifier); - const ast = parseQuery(query); - if (!node || !ast) { - return query; - } - - return lucene.toString(removeNodeFromTree(ast, node)); -} - -function removeNodeFromTree(ast: AST, node: NodeTerm): AST { - // {} - if (Object.keys(ast).length === 0) { - return ast; - } - // { left: {}, right: {} } or { left: {} } - if (isAST(ast.left)) { - ast.left = removeNodeFromTree(ast.left, node); - return ast; - } - if (isNodeTerm(ast.left) && isEqual(ast.left, node)) { - Object.assign( - ast, - { - left: undefined, - operator: undefined, - right: undefined, - }, - 'right' in ast ? ast.right : {} - ); - return ast; - } - if (isLeftOnlyAST(ast)) { - return ast; - } - if (isNodeTerm(ast.right) && isEqual(ast.right, node)) { - Object.assign(ast, { - right: undefined, - operator: undefined, - }); - return ast; - } - if (isBinaryAST(ast.right)) { - ast.right = removeNodeFromTree(ast.right, node); - return ast; - } - return ast; -} - -/** - * Filters can possibly reserved characters such as colons which are part of the Lucene syntax. - * Use this function to escape filter keys. - */ -export function escapeFilter(value: string) { - return lucene.term.escape(value); -} - -/** - * Values can possibly reserved special characters such as quotes. - * Use this function to escape filter values. - */ -export function escapeFilterValue(value: string) { - value = value.replace(/\\/g, '\\\\'); - return lucene.phrase.escape(value); -} - -/** - * Normalizes the query by removing whitespace around colons, which breaks parsing. - */ -function normalizeQuery(query: string) { - return query.replace(/(\w+)\s(:)/gi, '$1$2'); -} - -function isLeftOnlyAST(ast: unknown): ast is LeftOnlyAST { - if (!ast || typeof ast !== 'object') { - return false; - } - - if ('left' in ast && !('right' in ast)) { - return true; - } - - return false; -} - -function isBinaryAST(ast: unknown): ast is BinaryAST { - if (!ast || typeof ast !== 'object') { - return false; - } - - if ('left' in ast && 'right' in ast) { - return true; - } - return false; -} - -function isAST(ast: unknown): ast is AST { - return isLeftOnlyAST(ast) || isBinaryAST(ast); -} - -function isNodeTerm(ast: unknown): ast is NodeTerm { - if (ast && typeof ast === 'object' && 'term' in ast) { - return true; - } - - return false; -} - -function parseQuery(query: string) { - try { - return lucene.parse(normalizeQuery(query)); - } catch (e) { - return null; - } -} - -export function addStringFilterToQuery(query: string, filter: string, contains = true) { - const expression = `"${escapeFilterValue(filter)}"`; - return query.trim() ? `${query} ${contains ? 'AND' : 'NOT'} ${expression}` : `${contains ? '' : 'NOT '}${expression}`; -} diff --git a/src/quickwit.ts b/src/quickwit.ts index fa886db..94e3725 100644 --- a/src/quickwit.ts +++ b/src/quickwit.ts @@ -10,13 +10,3 @@ export interface QuickwitOptions extends DataSourceJsonData { dataLinks?: DataLinkConfig[]; index: string; } - -export interface LogRowContextOptions { - direction?: LogRowContextQueryDirection; - limit?: number; -} - -export enum LogRowContextQueryDirection { - Backward = 'BACKWARD', - Forward = 'FORWARD', -} diff --git a/src/utils/context.ts b/src/utils/context.ts new file mode 100644 index 0000000..0fc6cbf --- /dev/null +++ b/src/utils/context.ts @@ -0,0 +1,17 @@ +import { Context, useContext } from 'react'; + + +interface GetHook { + (context: Context): () => NonNullable; +} + +export const getHook: GetHook = (c) => () => { + const contextValue = useContext(c); + + if (!contextValue) { + throw new Error(`use context first.`); + } + + return contextValue as NonNullable; +}; + diff --git a/src/utils.test.ts b/src/utils/index.test.ts similarity index 100% rename from src/utils.test.ts rename to src/utils/index.test.ts diff --git a/src/utils.ts b/src/utils/index.ts similarity index 93% rename from src/utils.ts rename to src/utils/index.ts index ce30f4c..8e5a826 100644 --- a/src/utils.ts +++ b/src/utils/index.ts @@ -1,8 +1,8 @@ // import { gte, SemVer } from 'semver'; -import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; -import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; -import { MetricAggregation, MetricAggregationWithInlineScript } from './types'; +import { isMetricAggregationWithField } from '../components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { metricAggregationConfig } from '../components/QueryEditor/MetricAggregationsEditor/utils'; +import { MetricAggregation, MetricAggregationWithInlineScript } from '../types'; export const describeMetric = (metric: MetricAggregation) => { if (!isMetricAggregationWithField(metric)) { diff --git a/src/utils/lucene.ts b/src/utils/lucene.ts new file mode 100644 index 0000000..8b3580a --- /dev/null +++ b/src/utils/lucene.ts @@ -0,0 +1,212 @@ +import { isEqual } from 'lodash'; +import lucene, { AST, BinaryAST, LeftOnlyAST, NodeTerm } from 'lucene'; +export type { AST, BinaryAST, LeftOnlyAST, NodeTerm } from 'lucene'; + +export type ModifierType = '' | '-'; + +export type ParseError = { + name: string, + message: string, + location: { + start: {line: number, column: number, offset: number}, + end: {line: number, column: number, offset: number}, + } +} + +// Type predicates + +export function isLeftOnlyAST(ast: unknown): ast is LeftOnlyAST { + if (!ast || typeof ast !== 'object') { + return false; + } + + if ('left' in ast && !('right' in ast)) { + return true; + } + + return false; +} +export function isBinaryAST(ast: unknown): ast is BinaryAST { + if (!ast || typeof ast !== 'object') { + return false; + } + + if ('left' in ast && 'right' in ast) { + return true; + } + return false; +} + +export function isAST(ast: unknown): ast is AST { + return isLeftOnlyAST(ast) || isBinaryAST(ast); +} + +export function isNodeTerm(ast: unknown): ast is NodeTerm { + if (ast && typeof ast === 'object' && 'term' in ast) { + return true; + } + + return false; +} + + +/** + * Normalizes the query by removing whitespace around colons, which breaks parsing. + */ +function normalizeQuery(query: string) { + return query.replace(/(\w+)\s(:)/gi, '$1$2'); +} + +/** + * Filters can possibly reserved characters such as colons which are part of the Lucene syntax. + * Use this function to escape filter keys. + */ + +export function escapeFilter(value: string) { + return lucene.term.escape(value); +} +/** + * Values can possibly reserved special characters such as quotes. + * Use this function to escape filter values. + */ + +export function escapeFilterValue(value: string) { + value = value.replace(/\\/g, '\\\\'); + return lucene.phrase.escape(value); +} + + +function findNodeInTree(ast: AST, field: string, value: string): NodeTerm | null { + // {} + if (Object.keys(ast).length === 0) { + return null; + } + // { left: {}, right: {} } or { left: {} } + if (isAST(ast.left)) { + return findNodeInTree(ast.left, field, value); + } + if (isNodeTerm(ast.left) && ast.left.field === field && ast.left.term === value) { + return ast.left; + } + if (isLeftOnlyAST(ast)) { + return null; + } + if (isNodeTerm(ast.right) && ast.right.field === field && ast.right.term === value) { + return ast.right; + } + if (isBinaryAST(ast.right)) { + return findNodeInTree(ast.right, field, value); + } + return null; +} + +function removeNodeFromTree(ast: AST, node: NodeTerm): AST { + // {} + if (Object.keys(ast).length === 0) { + return ast; + } + // { left: {}, right: {} } or { left: {} } + if (isAST(ast.left)) { + ast.left = removeNodeFromTree(ast.left, node); + return ast; + } + if (isNodeTerm(ast.left) && isEqual(ast.left, node)) { + Object.assign( + ast, + { + left: undefined, + operator: undefined, + right: undefined, + }, + 'right' in ast ? ast.right : {} + ); + return ast; + } + if (isLeftOnlyAST(ast)) { + return ast; + } + if (isNodeTerm(ast.right) && isEqual(ast.right, node)) { + Object.assign(ast, { + right: undefined, + operator: undefined, + }); + return ast; + } + if (isBinaryAST(ast.right)) { + ast.right = removeNodeFromTree(ast.right, node); + return ast; + } + return ast; +} + +/** + * Merge a query with a filter. + */ +export function concatenate(query: string, filter: string, condition = 'AND'): string { + if (!filter) { + return query; + } + return query.trim() === '' ? filter : `${query} ${condition} ${filter}`; +} + +export class LuceneQuery { + ast: AST | null; + source: string | null; + parseError: any + + constructor(ast: AST|null, source?: string, error?: ParseError){ + this.ast = ast; + this.source = source || null; + this.parseError = error || null; + } + + static parse(query: string){ + let parsedQuery, parseError; + try { + parsedQuery = lucene.parse(normalizeQuery(query)); + } catch (e: any) { + parsedQuery = null; + parseError = e + } + return new LuceneQuery(parsedQuery, query, parseError) + } + + findFilter( key: string, value: string, modifier: ModifierType = ''){ + const field = `${modifier}${lucene.term.escape(key)}`; + value = lucene.phrase.escape(value); + if (!this.ast) { + return null; + } + + return findNodeInTree(this.ast, field, value); + } + + hasFilter(key: string, value: string, modifier: ModifierType = ''){ + return this.findFilter(key, value, modifier) !== null; + } + + addFilter(key: string, value: string, modifier: ModifierType = ''){ + if (this.hasFilter(key, value, modifier)) { + return this; + } + + key = escapeFilter(key); + value = escapeFilterValue(value); + const filter = `${modifier}${key}:"${value}"`; + + return LuceneQuery.parse(concatenate(this.toString(), filter)); + } + + removeFilter(key: string, value: string, modifier: ModifierType = ''){ + const node = this.findFilter(key, value, modifier); + if (!node || !this.ast) { + return this; + } + + return new LuceneQuery(removeNodeFromTree(this.ast, node)); + } + + toString() { + return this.source ? this.source : this.ast ? lucene.toString(this.ast) : ""; + } +}