LogsQueryField.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { css } from '@emotion/css';
  2. import { debounce, intersectionBy, unionBy } from 'lodash';
  3. import { LanguageMap, languages as prismLanguages } from 'prismjs';
  4. import React, { ReactNode } from 'react';
  5. import { Editor, Node, Plugin } from 'slate';
  6. import { AbsoluteTimeRange, QueryEditorProps, SelectableValue } from '@grafana/data';
  7. import {
  8. BracesPlugin,
  9. LegacyForms,
  10. MultiSelect,
  11. QueryField,
  12. SlatePrism,
  13. TypeaheadInput,
  14. TypeaheadOutput,
  15. } from '@grafana/ui';
  16. import { InputActionMeta } from '@grafana/ui/src/components/Select/types';
  17. import { notifyApp } from 'app/core/actions';
  18. import { createErrorNotification } from 'app/core/copy/appNotification';
  19. import { dispatch } from 'app/store/store';
  20. import { ExploreId } from 'app/types';
  21. // Utils & Services
  22. // dom also includes Element polyfills
  23. import { CloudWatchDatasource } from '../datasource';
  24. import { CloudWatchLanguageProvider } from '../language_provider';
  25. import syntax from '../syntax';
  26. import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types';
  27. import { getStatsGroups } from '../utils/query/getStatsGroups';
  28. import { appendTemplateVariables } from '../utils/utils';
  29. import QueryHeader from './QueryHeader';
  30. export interface CloudWatchLogsQueryFieldProps
  31. extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> {
  32. absoluteRange: AbsoluteTimeRange;
  33. onLabelsRefresh?: () => void;
  34. ExtraFieldElement?: ReactNode;
  35. exploreId: ExploreId;
  36. allowCustomValue?: boolean;
  37. }
  38. const containerClass = css`
  39. flex-grow: 1;
  40. min-height: 35px;
  41. `;
  42. const rowGap = css`
  43. gap: 3px;
  44. `;
  45. interface State {
  46. selectedLogGroups: Array<SelectableValue<string>>;
  47. availableLogGroups: Array<SelectableValue<string>>;
  48. loadingLogGroups: boolean;
  49. invalidLogGroups: boolean;
  50. hint:
  51. | {
  52. message: string;
  53. fix: {
  54. label: string;
  55. action: () => void;
  56. };
  57. }
  58. | undefined;
  59. }
  60. export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFieldProps, State> {
  61. state: State = {
  62. selectedLogGroups:
  63. (this.props.query as CloudWatchLogsQuery).logGroupNames?.map((logGroup) => ({
  64. value: logGroup,
  65. label: logGroup,
  66. })) ?? [],
  67. availableLogGroups: [],
  68. invalidLogGroups: false,
  69. loadingLogGroups: false,
  70. hint: undefined,
  71. };
  72. plugins: Plugin[];
  73. constructor(props: CloudWatchLogsQueryFieldProps, context: React.Context<any>) {
  74. super(props, context);
  75. this.plugins = [
  76. BracesPlugin(),
  77. SlatePrism(
  78. {
  79. onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
  80. getSyntax: (node: Node) => 'cloudwatch',
  81. },
  82. { ...(prismLanguages as LanguageMap), cloudwatch: syntax }
  83. ),
  84. ];
  85. }
  86. fetchLogGroupOptions = async (region: string, logGroupNamePrefix?: string) => {
  87. try {
  88. const logGroups: string[] = await this.props.datasource.describeLogGroups({
  89. refId: this.props.query.refId,
  90. region,
  91. logGroupNamePrefix,
  92. });
  93. return logGroups.map((logGroup) => ({
  94. value: logGroup,
  95. label: logGroup,
  96. }));
  97. } catch (err) {
  98. let errMessage = 'unknown error';
  99. if (typeof err !== 'string') {
  100. try {
  101. errMessage = JSON.stringify(err);
  102. } catch (e) {}
  103. } else {
  104. errMessage = err;
  105. }
  106. dispatch(notifyApp(createErrorNotification(errMessage)));
  107. return [];
  108. }
  109. };
  110. onLogGroupSearch = (searchTerm: string, region: string, actionMeta: InputActionMeta) => {
  111. if (actionMeta.action !== 'input-change') {
  112. return Promise.resolve();
  113. }
  114. // No need to fetch matching log groups if the search term isn't valid
  115. // This is also useful for preventing searches when a user is typing out a log group with template vars
  116. // See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_LogGroup.html for the source of the pattern below
  117. const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/;
  118. if (!logGroupNamePattern.test(searchTerm)) {
  119. return Promise.resolve();
  120. }
  121. this.setState({
  122. loadingLogGroups: true,
  123. });
  124. return this.fetchLogGroupOptions(region, searchTerm)
  125. .then((matchingLogGroups) => {
  126. this.setState((state) => ({
  127. availableLogGroups: unionBy(state.availableLogGroups, matchingLogGroups, 'value'),
  128. }));
  129. })
  130. .finally(() => {
  131. this.setState({
  132. loadingLogGroups: false,
  133. });
  134. });
  135. };
  136. onLogGroupSearchDebounced = debounce(this.onLogGroupSearch, 300);
  137. componentDidMount = () => {
  138. const { query, onChange } = this.props;
  139. this.setState({
  140. loadingLogGroups: true,
  141. });
  142. query.region &&
  143. this.fetchLogGroupOptions(query.region).then((logGroups) => {
  144. this.setState((state) => {
  145. const selectedLogGroups = state.selectedLogGroups;
  146. if (onChange) {
  147. const nextQuery = {
  148. ...query,
  149. logGroupNames: selectedLogGroups.map((group) => group.value!),
  150. };
  151. onChange(nextQuery);
  152. }
  153. return {
  154. loadingLogGroups: false,
  155. availableLogGroups: logGroups,
  156. selectedLogGroups,
  157. };
  158. });
  159. });
  160. };
  161. onChangeQuery = (value: string) => {
  162. // Send text change to parent
  163. const { query, onChange } = this.props;
  164. const { selectedLogGroups } = this.state;
  165. if (onChange) {
  166. const nextQuery = {
  167. ...query,
  168. expression: value,
  169. logGroupNames: selectedLogGroups?.map((logGroupName) => logGroupName.value!) ?? [],
  170. statsGroups: getStatsGroups(value),
  171. };
  172. onChange(nextQuery);
  173. }
  174. };
  175. setSelectedLogGroups = (selectedLogGroups: Array<SelectableValue<string>>) => {
  176. this.setState({
  177. selectedLogGroups,
  178. });
  179. const { onChange, query } = this.props;
  180. onChange?.({
  181. ...(query as CloudWatchLogsQuery),
  182. logGroupNames: selectedLogGroups.map((logGroupName) => logGroupName.value!) ?? [],
  183. });
  184. };
  185. setCustomLogGroups = (v: string) => {
  186. const customLogGroup: SelectableValue<string> = { value: v, label: v };
  187. const selectedLogGroups = [...this.state.selectedLogGroups, customLogGroup];
  188. this.setSelectedLogGroups(selectedLogGroups);
  189. };
  190. onRegionChange = async (v: string) => {
  191. this.setState({
  192. loadingLogGroups: true,
  193. });
  194. const logGroups = await this.fetchLogGroupOptions(v);
  195. this.setState((state) => {
  196. const selectedLogGroups = intersectionBy(state.selectedLogGroups, logGroups, 'value');
  197. const { onChange, query } = this.props;
  198. if (onChange) {
  199. const nextQuery = {
  200. ...query,
  201. logGroupNames: selectedLogGroups.map((group) => group.value!),
  202. };
  203. onChange(nextQuery);
  204. }
  205. return {
  206. availableLogGroups: logGroups,
  207. selectedLogGroups: selectedLogGroups,
  208. loadingLogGroups: false,
  209. };
  210. });
  211. };
  212. onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
  213. const { datasource, query } = this.props;
  214. const { selectedLogGroups } = this.state;
  215. if (!datasource.languageProvider) {
  216. return { suggestions: [] };
  217. }
  218. const cloudwatchLanguageProvider = datasource.languageProvider as CloudWatchLanguageProvider;
  219. const { history, absoluteRange } = this.props;
  220. const { prefix, text, value, wrapperClasses, labelKey, editor } = typeahead;
  221. return await cloudwatchLanguageProvider.provideCompletionItems(
  222. { text, value, prefix, wrapperClasses, labelKey, editor },
  223. {
  224. history,
  225. absoluteRange,
  226. logGroupNames: selectedLogGroups.map((logGroup) => logGroup.value!),
  227. region: query.region,
  228. }
  229. );
  230. };
  231. onQueryFieldClick = (_event: Event, _editor: Editor, next: () => any) => {
  232. const { selectedLogGroups, loadingLogGroups } = this.state;
  233. const queryFieldDisabled = loadingLogGroups || selectedLogGroups.length === 0;
  234. if (queryFieldDisabled) {
  235. this.setState({
  236. invalidLogGroups: true,
  237. });
  238. }
  239. next();
  240. };
  241. onOpenLogGroupMenu = () => {
  242. this.setState({
  243. invalidLogGroups: false,
  244. });
  245. };
  246. render() {
  247. const { onRunQuery, onChange, ExtraFieldElement, data, query, datasource, allowCustomValue } = this.props;
  248. const { selectedLogGroups, availableLogGroups, loadingLogGroups, hint, invalidLogGroups } = this.state;
  249. const showError = data && data.error && data.error.refId === query.refId;
  250. const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
  251. const MAX_LOG_GROUPS = 20;
  252. return (
  253. <>
  254. <QueryHeader
  255. query={query}
  256. onRunQuery={onRunQuery}
  257. datasource={datasource}
  258. onChange={onChange}
  259. sqlCodeEditorIsDirty={false}
  260. onRegionChange={this.onRegionChange}
  261. />
  262. <div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
  263. <LegacyForms.FormField
  264. label="Log Groups"
  265. labelWidth={6}
  266. className="flex-grow-1"
  267. inputEl={
  268. <MultiSelect
  269. aria-label="Log Groups"
  270. allowCustomValue={allowCustomValue}
  271. options={appendTemplateVariables(datasource, unionBy(availableLogGroups, selectedLogGroups, 'value'))}
  272. value={selectedLogGroups}
  273. onChange={(v) => {
  274. this.setSelectedLogGroups(v);
  275. }}
  276. onCreateOption={(v) => {
  277. this.setCustomLogGroups(v);
  278. }}
  279. onBlur={this.props.onRunQuery}
  280. className={containerClass}
  281. closeMenuOnSelect={false}
  282. isClearable={true}
  283. invalid={invalidLogGroups}
  284. isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}
  285. placeholder="Choose Log Groups"
  286. maxVisibleValues={4}
  287. noOptionsMessage="No log groups available"
  288. isLoading={loadingLogGroups}
  289. onOpenMenu={this.onOpenLogGroupMenu}
  290. onInputChange={(value, actionMeta) => {
  291. this.onLogGroupSearchDebounced(value, query.region, actionMeta);
  292. }}
  293. />
  294. }
  295. />
  296. </div>
  297. <div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
  298. <div className="gf-form gf-form--grow flex-shrink-1">
  299. <QueryField
  300. additionalPlugins={this.plugins}
  301. query={(query as CloudWatchLogsQuery).expression ?? ''}
  302. onChange={this.onChangeQuery}
  303. onClick={this.onQueryFieldClick}
  304. onRunQuery={this.props.onRunQuery}
  305. onTypeahead={this.onTypeahead}
  306. cleanText={cleanText}
  307. placeholder="Enter a CloudWatch Logs Insights query (run with Shift+Enter)"
  308. portalOrigin="cloudwatch"
  309. disabled={loadingLogGroups || selectedLogGroups.length === 0}
  310. />
  311. </div>
  312. {ExtraFieldElement}
  313. </div>
  314. {hint && (
  315. <div className="query-row-break">
  316. <div className="text-warning">
  317. {hint.message}
  318. <a className="text-link muted" onClick={hint.fix.action}>
  319. {hint.fix.label}
  320. </a>
  321. </div>
  322. </div>
  323. )}
  324. {showError ? (
  325. <div className="query-row-break">
  326. <div className="prom-query-field-info text-error">{data?.error?.message}</div>
  327. </div>
  328. ) : null}
  329. </>
  330. );
  331. }
  332. }