1import React, { ReactNode } from 'react'; 2 3import { Plugin } from 'slate'; 4import { 5 SlatePrism, 6 TypeaheadInput, 7 TypeaheadOutput, 8 BracesPlugin, 9 DOMUtil, 10 SuggestionsState, 11 Icon, 12} from '@grafana/ui'; 13 14import { LanguageMap, languages as prismLanguages } from 'prismjs'; 15 16// dom also includes Element polyfills 17import { PromQuery, PromOptions } from '../types'; 18import { roundMsToMin } from '../language_utils'; 19import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; 20import { QueryEditorProps, QueryHint, isDataFrame, toLegacyResponseData, TimeRange, CoreApp } from '@grafana/data'; 21import { PrometheusDatasource } from '../datasource'; 22import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; 23import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; 24import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; 25 26export const RECORDING_RULES_GROUP = '__recording_rules__'; 27const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; 28 29function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) { 30 if (metricsLookupDisabled) { 31 return '(Disabled)'; 32 } 33 34 if (!hasSyntax) { 35 return 'Loading metrics...'; 36 } 37 38 if (!hasMetrics) { 39 return '(No metrics found)'; 40 } 41 42 return 'Metrics browser'; 43} 44 45export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { 46 // Modify suggestion based on context 47 switch (typeaheadContext) { 48 case 'context-labels': { 49 const nextChar = DOMUtil.getNextCharacter(); 50 if (!nextChar || nextChar === '}' || nextChar === ',') { 51 suggestion += '='; 52 } 53 break; 54 } 55 56 case 'context-label-values': { 57 // Always add quotes and remove existing ones instead 58 if (!typeaheadText.match(/^(!?=~?"|")/)) { 59 suggestion = `"${suggestion}`; 60 } 61 if (DOMUtil.getNextCharacter() !== '"') { 62 suggestion = `${suggestion}"`; 63 } 64 break; 65 } 66 67 default: 68 } 69 return suggestion; 70} 71 72interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> { 73 ExtraFieldElement?: ReactNode; 74 'data-testid'?: string; 75} 76 77interface PromQueryFieldState { 78 labelBrowserVisible: boolean; 79 syntaxLoaded: boolean; 80 hint: QueryHint | null; 81} 82 83class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> { 84 plugins: Plugin[]; 85 declare languageProviderInitializationPromise: CancelablePromise<any>; 86 87 constructor(props: PromQueryFieldProps, context: React.Context<any>) { 88 super(props, context); 89 90 this.plugins = [ 91 BracesPlugin(), 92 SlatePrism( 93 { 94 onlyIn: (node: any) => node.type === 'code_block', 95 getSyntax: (node: any) => 'promql', 96 }, 97 { ...(prismLanguages as LanguageMap), promql: this.props.datasource.languageProvider.syntax } 98 ), 99 ]; 100 101 this.state = { 102 labelBrowserVisible: false, 103 syntaxLoaded: false, 104 hint: null, 105 }; 106 } 107 108 componentDidMount() { 109 if (this.props.datasource.languageProvider) { 110 this.refreshMetrics(); 111 } 112 this.refreshHint(); 113 } 114 115 componentWillUnmount() { 116 if (this.languageProviderInitializationPromise) { 117 this.languageProviderInitializationPromise.cancel(); 118 } 119 } 120 121 componentDidUpdate(prevProps: PromQueryFieldProps) { 122 const { 123 data, 124 datasource: { languageProvider }, 125 range, 126 } = this.props; 127 128 if (languageProvider !== prevProps.datasource.languageProvider) { 129 // We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every 130 // query run if using relative range. 131 this.setState({ 132 syntaxLoaded: false, 133 }); 134 } 135 136 const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range); 137 // We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute) 138 if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) { 139 this.refreshMetrics(); 140 } 141 142 if (data && prevProps.data && prevProps.data.series !== data.series) { 143 this.refreshHint(); 144 } 145 } 146 147 refreshHint = () => { 148 const { datasource, query, data } = this.props; 149 const initHints = datasource.getInitHints(); 150 const initHint = initHints.length > 0 ? initHints[0] : null; 151 152 if (!data || data.series.length === 0) { 153 this.setState({ 154 hint: initHint, 155 }); 156 return; 157 } 158 159 const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series; 160 const queryHints = datasource.getQueryHints(query, result); 161 let queryHint = queryHints.length > 0 ? queryHints[0] : null; 162 163 this.setState({ hint: queryHint ?? initHint }); 164 }; 165 166 refreshMetrics = async () => { 167 const { 168 datasource: { languageProvider }, 169 } = this.props; 170 171 this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start()); 172 173 try { 174 const remainingTasks = await this.languageProviderInitializationPromise.promise; 175 await Promise.all(remainingTasks); 176 this.onUpdateLanguage(); 177 } catch (err) { 178 if (!err.isCanceled) { 179 throw err; 180 } 181 } 182 }; 183 184 rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean { 185 if (range && prevRange) { 186 const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf()); 187 const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf()); 188 // If both are same, don't need to refresh. 189 return !(sameMinuteFrom && sameMinuteTo); 190 } 191 return false; 192 } 193 194 /** 195 * TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;) 196 */ 197 onChangeLabelBrowser = (selector: string) => { 198 this.onChangeQuery(selector, true); 199 this.setState({ labelBrowserVisible: false }); 200 }; 201 202 onChangeQuery = (value: string, override?: boolean) => { 203 // Send text change to parent 204 const { query, onChange, onRunQuery } = this.props; 205 if (onChange) { 206 const nextQuery: PromQuery = { ...query, expr: value }; 207 onChange(nextQuery); 208 209 if (override && onRunQuery) { 210 onRunQuery(); 211 } 212 } 213 }; 214 215 onClickChooserButton = () => { 216 this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible })); 217 }; 218 219 onClickHintFix = () => { 220 const { datasource, query, onChange, onRunQuery } = this.props; 221 const { hint } = this.state; 222 223 onChange(datasource.modifyQuery(query, hint!.fix!.action)); 224 onRunQuery(); 225 }; 226 227 onUpdateLanguage = () => { 228 const { 229 datasource: { languageProvider }, 230 } = this.props; 231 const { metrics } = languageProvider; 232 233 if (!metrics) { 234 return; 235 } 236 237 this.setState({ syntaxLoaded: true }); 238 }; 239 240 onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => { 241 const { 242 datasource: { languageProvider }, 243 } = this.props; 244 245 if (!languageProvider) { 246 return { suggestions: [] }; 247 } 248 249 const { history } = this.props; 250 const { prefix, text, value, wrapperClasses, labelKey } = typeahead; 251 252 const result = await languageProvider.provideCompletionItems( 253 { text, value, prefix, wrapperClasses, labelKey }, 254 { history } 255 ); 256 257 return result; 258 }; 259 260 render() { 261 const { 262 datasource, 263 datasource: { languageProvider }, 264 query, 265 ExtraFieldElement, 266 history = [], 267 } = this.props; 268 269 const { labelBrowserVisible, syntaxLoaded, hint } = this.state; 270 const hasMetrics = languageProvider.metrics.length > 0; 271 const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); 272 const buttonDisabled = !(syntaxLoaded && hasMetrics); 273 274 return ( 275 <LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> 276 {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { 277 return ( 278 <> 279 <div 280 className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1" 281 data-testid={this.props['data-testid']} 282 > 283 <button 284 className="gf-form-label query-keyword pointer" 285 onClick={this.onClickChooserButton} 286 disabled={buttonDisabled} 287 > 288 {chooserText} 289 <Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} /> 290 </button> 291 292 <div className="gf-form gf-form--grow flex-shrink-1 min-width-15"> 293 <MonacoQueryFieldWrapper 294 runQueryOnBlur={this.props.app !== CoreApp.Explore} 295 languageProvider={languageProvider} 296 history={history} 297 onChange={this.onChangeQuery} 298 onRunQuery={this.props.onRunQuery} 299 initialValue={query.expr ?? ''} 300 /> 301 </div> 302 </div> 303 {labelBrowserVisible && ( 304 <div className="gf-form"> 305 <PrometheusMetricsBrowser 306 languageProvider={languageProvider} 307 onChange={this.onChangeLabelBrowser} 308 lastUsedLabels={lastUsedLabels || []} 309 storeLastUsedLabels={onLastUsedLabelsSave} 310 deleteLastUsedLabels={onLastUsedLabelsDelete} 311 /> 312 </div> 313 )} 314 315 {ExtraFieldElement} 316 {hint ? ( 317 <div className="query-row-break"> 318 <div className="prom-query-field-info text-warning"> 319 {hint.label}{' '} 320 {hint.fix ? ( 321 <a className="text-link muted" onClick={this.onClickHintFix}> 322 {hint.fix.label} 323 </a> 324 ) : null} 325 </div> 326 </div> 327 ) : null} 328 </> 329 ); 330 }} 331 </LocalStorageValueProvider> 332 ); 333 } 334} 335 336export default PromQueryField; 337